如下图,在AS(net472.v0.16.47)中打开,没有路径位置,所以没法直接用UnityLive2DExtractor这工具导出
如果有佬知道的话请在下面告诉我,感激不尽
他不是加密了吗?
大佬细说
巧了,我也遇到这个问题,去年想提取解神者,发现每个角色的live2d文件每部分都散乱的单独打包,只能提spine,我觉得这个要把资源目录的文件给解了,看看它是怎么放的才行
@Akaset
去Unitypy的群开贴问了一下,大佬给我了一些Dump出来的dll,需要指定它们来读取MonoBehaviour资源,我也上传到这里一起研究好了
DummyDll.zip (6.0 MB)
找别的佬又看了一下,模型加密了,要反编译看代码才知道怎么解,或者从内存中dump
更新
它所有的 ab 的 container 路径都是由一个叫 globalgamemanagers的文件来控制 (?),如果去除这个文件去加载全部文件,那么剩下的文件路径都不会加载出来,这文件的前面是所有data名,后面是一些container路径,最后还有一堆数据
找到一个笨方法可以提取解神者的moc3文件,虽然文件十分混乱还是可以通过尝试来找出live2d的moc3与贴图部分,具体思路如下:
1.先把live2d的尸块图片都通过as提取出来
2.根据wiki或者其他网站找出人物的图片,这里就拿楼主的示意图片来举例,通过尸块最后猜测出图片可能是莉莉丝#神格皮肤2的,网站网址https://wiki.biligame.com/x2/%E8%8E%89%E8%8E%89%E4%B8%9D#%E7%A5%9E%E6%A0%BC%E7%9A%AE%E8%82%A42
3.在as中搜索有关线索,这里是莉莉丝,经过尝试是搜索lilisu,type选择mono文件
4.将所有可能的文件右击搜索它的原始位置,并将其重命名
5.将文件放入hex编辑器里并搜索moc这样的关键字,如图
发现moc3二进制部分,使用python将这部分截取下来作为moc3文件
6.python代码部分,图省事就ChatGPT来完成了,记得修改路径位置
7.使用live2dexviewer进行尝试组合,如果不成功或者图片碎了可能是贴图找错了
8.最终效果
动作你搜idle这类关键字也能搜到,带@的应该就是二进制的动作文件 as里面无法浏览内容 我没试能不能直接截取然后当动作用 方法应该和moc3的一样 那个live2dextractor 好像可以把这个动作转为json格式的 我不知道怎么转 所以也没有去尝试提取
将我之前的python代码中的 moc_index = content.find(b’MOC’) 这一行的MOC换成这个idle的名称 这里是TianShu@Idle03就可以截取了 记得修改后缀 不过byte形式的动作我不知道live2dexviewer识别的是什么后缀 估计live2dexviewer可能读不出来 我逆向水平有限 只能提供点思路
不太会python。使用assetstudio(就云图那个)导出anim文件,跑一下就行了。我试过了,是没有问题的。这个是在昨天发布的云图上改的,稳定性会更好。
搞这个anim 转换为 json一开始就想着简单,早知道一开始就用正则了。然后转换格式。写了三个小时,麻了。有两个小时在调bug。
解牛者的物理文件转换代码还没写。不想搞了
import fs from 'fs'
import path from 'path'
// 每层空格多少个
const spaceCeil = 2
const numRexg = /^-?\d+\.?\d*$/
const CubismPhysicsControllerRexg = /CubismPhysicsController/
function hangle(lines, index, ceil, falg) {
let data = {}
let arrayData = []
for (let i = index; i < lines.length; i++) {
let line = lines[i];
// 确认层级
let spaceNum = 0
for (let j = 0; j < line.length && (line[j] === ' ' || line[j] === '-'); j++) {
spaceNum++
}
if (spaceNum / spaceCeil < ceil) {
return {
value: arrayData.length ? arrayData : Object.keys(data).length ? data : [],
index: i - 1
}
}
if (line.trim() === '') {
continue;
}
const parts = line.split(':');
let key = parts[0].trim();
if (key.startsWith('- ')) {
// key = key.split("-")[1].trim()
// arrayData.push(data)
// line[ceil * spaceCeil] = ' '
if (falg) {
return {
value: data,
index: i - 1
}
} else {
lines[i] = line = line.substring(0, ceil * spaceCeil - spaceCeil) + ' ' + line.substring(ceil * spaceCeil + 1 - spaceCeil)
let result = hangle(lines, i, ceil, true)
arrayData.push(result.value)
i = result.index
}
continue
}
let value = parts[1]?.trim()
if (value.length === 0) {
// console.log(key);
let result = hangle(lines, i + 1, ceil + 1, false)
// console.log(result.value);
i = result.index
value = result.value
// console.log(key + ":" + JSON.stringify(value));
} else if (numRexg.test(value)) {
value = Number(value)
} else if (value === "[]") {
value = []
}
data[key] = value;
}
return {
value: arrayData.length ? arrayData : Object.keys(data).length ? data : [],
index: lines.length
}
}
function processFadeFiles(dirPath) {
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
processFadeFiles(filePath);
} else if (file.endsWith('.fade.json')) {
try {
const fileName = path.basename(file, '.fade.json');
const data = fs.readFileSync(filePath, 'utf8');
const obj = JSON.parse(data);
const motion3Json = {
'Version': 3,
"Meta": {
"Duration": 0.000,
"Fps": 60.0,
"Loop": true,
"AreBeziersRestricted": true,
"CurveCount": 0,
"TotalSegmentCount": 0,
"TotalPointCount": 0,
"UserDataCount": 1,
"TotalUserDataSize": 0
},
"Curves": [],
"UserData": [
{
"Time": 0.0,
"Value": ""
}
]
};
// motion3Json.Meta.TotalSegmentCount = obj.ParameterIds * 10
// motion3Json.Meta.TotalSegmentCount = obj.ParameterIds * 15
let TotalSegmentCount = 0
let maxTime = 0.0
for (let i = 0; i < obj.ParameterCurves.length; i++) {
let Segments = []
for (let j = 0; j < obj.ParameterCurves[i].m_Curve.length; j++) {
TotalSegmentCount++;
Segments.push(obj.ParameterCurves[i].m_Curve[j].time ?? 0)
Segments.push(obj.ParameterCurves[i].m_Curve[j].value ?? 0)
Segments.push(obj.ParameterCurves[i].m_Curve[j].weightedMode ?? 0)
maxTime = maxTime > obj.ParameterCurves[i].m_Curve[j].time ? maxTime : obj.ParameterCurves[i].m_Curve[j].time
}
Segments.pop()
motion3Json.Curves.push({
"Target": "Parameter",
"Id": obj.ParameterIds[i],
"Segments": Segments
})
}
motion3Json.Meta.CurveCount = obj.ParameterIds.length
motion3Json.Meta.Duration = maxTime
motion3Json.Meta.TotalSegmentCount = TotalSegmentCount
motion3Json.Meta.TotalPointCount = obj.ParameterIds.length + TotalSegmentCount
fs.writeFileSync(path.join(dirPath, `${fileName}.motion3.json`), JSON.stringify(motion3Json, '\t'));
console.log(path.join(dirPath, `${fileName}.motion3.json`) + "已生成");
} catch (e) {
console.log(e);
console.log(path.join(dirPath, `${fileName}.motion3.json`) + "转化失败");
}
} else if (CubismPhysicsControllerRexg.test(file)) {
try {
const data = fs.readFileSync(filePath, 'utf8');
const obj = JSON.parse(data);
let physicsJson = {
"Version": 3,
"Meta": {
"PhysicsSettingCount": 0,
"TotalInputCount": 0,
"TotalOutputCount": 0,
"VertexCount": 0,
"Fps": 0,
"EffectiveForces": {
},
"PhysicsDictionary": [
]
},
"PhysicsSettings": []
}
physicsJson.Meta.EffectiveForces.Gravity = obj?._rig?.Gravity
physicsJson.Meta.EffectiveForces.Wind = obj?._rig?.Wind
physicsJson.Meta.Fps = obj._rig?.Fps ?? 60
for (let i = 0; i < obj._rig?.SubRigs?.length ?? 0; i++) {
let physicsSetting = {
"Id": "PhysicsSetting",
"Input": [
],
"Output": [
],
"Vertices": [
],
"Normalization": {
}
}
let rig = obj._rig.SubRigs[i]
physicsSetting.Id = physicsSetting.Id + (i + 1)
physicsJson.Meta.PhysicsDictionary.push({
"Id": physicsSetting.Id,
"Name": i + 1 + ""
})
for (let j = 0; j < rig?.Input.length ?? 0; j++) {
physicsSetting.Input.push({
"Source": {
"Target": "Parameter",
"Id": rig.Input[j].SourceId
},
"Weight": rig.Input[j].Weight,
"Type": rig.Input[j].AngleScale || rig.Input[j].AngleScale === 0 ? "Angle" : "X",
"Reflect": false
})
}
for (let j = 0; j < rig?.Output.length ?? 0; j++) {
physicsSetting.Output.push({
"Destination": {
"Target": "Parameter",
"Id": rig.Output[j].DestinationId
},
"VertexIndex": 1,
"Scale": rig.Output[j].AngleScale ?? 1,
"Weight": rig.Output[j].Weight,
"Type": rig.Output[j].AngleScale || rig.Output[j].AngleScale === 0 ? "Angle" : "X",
"Reflect": false
})
}
for (let j = 0; j < rig?.Particles?.length; j++) {
physicsSetting.Vertices.push({
"Position": rig?.Particles[j].InitialPosition,
"Mobility": rig?.Particles[j].Mobility,
"Delay": rig?.Particles[j].Delay,
"Acceleration": rig?.Particles[j].Acceleration,
"Radius": rig?.Particles[j].Radius
})
}
physicsSetting.Normalization = rig.Normalization
physicsJson.PhysicsSettings.push(physicsSetting)
}
fs.writeFileSync(path.join(dirPath, `l2d${Math.ceil(Math.random() * 100000)}.physics3.json`), JSON.stringify(physicsJson, '\t'));
console.log(path.join(dirPath, `physics3.json`) + "已生成");
} catch (e) {
console.log(e);
console.log(path.join(dirPath, `physics3.json`) + "转化失败");
}
} else if (file.endsWith(".anim")) {
try {
const animTxt = fs.readFileSync(filePath, 'utf8');
let lines = animTxt.split("\n");
// console.log(lines[0]);
const animJson = hangle(lines, 3, 0).value
const motion3Json = {
'Version': 3,
"Meta": {
"Duration": 0.000,
"Fps": 60.0,
"Loop": true,
"AreBeziersRestricted": true,
"CurveCount": 0,
"TotalSegmentCount": 0,
"TotalPointCount": 0,
"UserDataCount": 1,
"TotalUserDataSize": 0
},
"Curves": [],
"UserData": [
{
"Time": 0.0,
"Value": ""
}
]
};
const fileName = path.basename(file, '.anim');
let TotalSegmentCount = 0
let maxTime = 0
for (let i = 0; i < animJson.AnimationClip.m_FloatCurves.length ?? 0; i++) {
let Segments = []
let curve = animJson.AnimationClip.m_FloatCurves[i].curve
for (let j = 0; j < curve.m_Curve.length ?? 0; j++) {
Segments.push(curve.m_Curve[j].time)
Segments.push(curve.m_Curve[j].value)
Segments.push(0)
if (maxTime < curve.m_Curve[j].time) {
maxTime = curve.m_Curve[j].time
}
}
Segments.pop()
TotalSegmentCount += curve.m_Curve.length
if (Object.prototype.toString.call(animJson.AnimationClip.m_FloatCurves[i].path) === "[object String]") {
// let Target, Id = animJson.AnimationClip.m_FloatCurves[i].path?.split("/")
motion3Json.Curves.push({
"Target": "Parameter",
"Id": animJson.AnimationClip.m_FloatCurves[i].path?.split("/")[1],
"Segments": Segments
})
}
}
motion3Json.Meta.Duration = maxTime
motion3Json.Meta.TotalSegmentCount = TotalSegmentCount
motion3Json.Meta.CurveCount = animJson.AnimationClip.m_FloatCurves.length
motion3Json.Meta.TotalPointCount = motion3Json.Meta.TotalSegmentCount + motion3Json.Meta.CurveCount
fs.writeFileSync(path.join(dirPath, `${fileName}.motion3.json`), JSON.stringify(motion3Json, '\t'))
console.log(path.join(dirPath, `${fileName}.motion3.json`) + '已生成');
} catch (err) {
console.log(err);
console.log(path.join(dirPath, file) + "转化失败");
}
}
}
}
processFadeFiles("D:\\新建文件夹\\qq\\Output");
佬好强,我试试
物理文件,assetstudio导不出来。
node.js脚本。你加我qq 1197158057我可以发你我跑完的(我不是卖片的)。python不是很熟,大二时python课都逃掉了。
有提好的资源吗
上面的 muyi123 佬做好后传LiveViewerEX的工坊了
这里总览地写一下live2d的导出方法,希望对大家有所帮助
1.MOC3文件
一开始导入as里面怎么找也找不到moc3文件,再加上也没有容器路径,没法用UnityLive2DExtract这工具导出,然后看到楼上 bakaneko 佬的回帖,才知道moc3文件只是前面放了UnityFs的标头+一堆空字符+角色拼音名(有些甚至没有名字)+moc3头,直接切掉多余的头好了,至此,总算迈出第一步了
直接在资源文件里面找moc3脚本
import os
import shutil
def find_and_copy_moc3_files(input_folder, output_folder):
if not os.path.exists(output_folder):
os.makedirs(output_folder)
# 16进制值"MOC3"对应的字节序列
moc3_bytes = bytes.fromhex("4D 4F 43 33")
for foldername, subfolders, filenames in os.walk(input_folder):
for filename in filenames:
file_path = os.path.join(foldername, filename)
with open(file_path, "rb") as file:
file_contents = file.read()
if moc3_bytes in file_contents:
output_file_path = os.path.join(output_folder, filename)
shutil.copy(file_path, output_file_path)
print(f"Copied: {file_path} to {output_file_path}")
if __name__ == "__main__":
input_folder = "/home/Neko/Downloads/Unpack Games/解神者_2.4/assets"
output_folder = "moc3"
find_and_copy_moc3_files(input_folder, output_folder)
切moc3标头前面的多余字符,并还原原本名称的脚本
const fs = require('fs');
const path = require('path');
function isNumAndAlphe(a) {
if((a >= 97 && a <= 122) || (a >= 65 && a <= 90) || (a >= 48 && a <= 57) || a === '-'.charCodeAt() || a === '_'.charCodeAt()) {
return true
}
return false
}
function deleteBinaryData1(filePath, directoryPath) {
const data = fs.readFileSync(filePath);
const mocIndex = data.indexOf('MOC');
// const fileName = path.basename(filePath)
let fileName = ''
if (mocIndex !== -1) {
// 寻找开始统计位
let i
for(i = mocIndex - 5; i >= 0 && !isNumAndAlphe(data[i]); i--) {
}
for(; i >= 0 && isNumAndAlphe(data[i]); i--) {
fileName = String.fromCharCode(data[i]) + fileName;
}
const newData = data.slice(mocIndex);
fs.writeFileSync(path.join(directoryPath, `${fileName}.moc3`), newData);
console.log(filePath + "->" + path.join(directoryPath, `${fileName}.moc3`));
}
}
function readDirectory1(directoryPath) {
const files = fs.readdirSync(directoryPath);
files.forEach((file) => {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
// readDirectory1(filePath);
}
try{
deleteBinaryData1(filePath, directoryPath);
}catch(e) {
console.log(e);
console.log(filePath + "转换失败")
}
});
}
readDirectory1("moc3")
更新moc3文件更为方便的导出方法:
这时候 muyi123 回帖,发了提取motions的脚本,我两加了QQ继续探讨后续
2.motions提取
这里用的是muyi123佬的提取脚本,但是代码的动画持续有点问题,用的是最晚时间减去最早时间,然后他在动画播放完到下一个动画播放前,会有一个卡顿
(尝试修正代码,但是反而时间变更大了,欢迎大佬们提出更好的动作提取方法,或者来修正代码)
const fs = require('fs');
const path = require('path');
// 每层空格多少个
const spaceCeil = 2
const numRexg = /^-?\d+\.?\d*$/
const CubismPhysicsControllerRexg = /CubismPhysicsController/
function hangle(lines, index, ceil, falg) {
let data = {}
let arrayData = []
for (let i = index; i < lines.length; i++) {
let line = lines[i];
// 确认层级
let spaceNum = 0
for (let j = 0; j < line.length && (line[j] === ' ' || line[j] === '-'); j++) {
spaceNum++
}
if (spaceNum / spaceCeil < ceil) {
return {
value: arrayData.length ? arrayData : Object.keys(data).length ? data : [],
index: i - 1
}
}
if (line.trim() === '') {
continue;
}
const parts = line.split(':');
let key = parts[0].trim();
if (key.startsWith('- ')) {
// key = key.split("-")[1].trim()
// arrayData.push(data)
// line[ceil * spaceCeil] = ' '
if (falg) {
return {
value: data,
index: i - 1
}
} else {
lines[i] = line = line.substring(0, ceil * spaceCeil - spaceCeil) + ' ' + line.substring(ceil * spaceCeil + 1 - spaceCeil)
let result = hangle(lines, i, ceil, true)
arrayData.push(result.value)
i = result.index
}
continue
}
let value = parts[1].trim()
if (value.length === 0) {
// console.log(key);
let result = hangle(lines, i + 1, ceil + 1, false)
// console.log(result.value);
i = result.index
value = result.value
// console.log(key + ":" + JSON.stringify(value));
} else if (numRexg.test(value)) {
value = Number(value)
} else if (value === "[]") {
value = []
}
data[key] = value;
}
return {
value: arrayData.length ? arrayData : Object.keys(data).length ? data : [],
index: lines.length
}
}
function processFadeFiles(dirPath) {
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
processFadeFiles(filePath);
} else if (file.endsWith('.fade.json')) {
const fileName = path.basename(file, '.fade.json');
const data = fs.readFileSync(filePath, 'utf8');
const obj = JSON.parse(data);
const motion3Json = {
'Version': 3,
"Meta": {
"Duration": 0.000,
"Fps": 60.0,
"Loop": true,
"AreBeziersRestricted": true,
"CurveCount": 0,
"TotalSegmentCount": 0,
"TotalPointCount": 0,
"UserDataCount": 1,
"TotalUserDataSize": 0
},
"Curves": [],
"UserData": [
{
"Time": 0.0,
"Value": ""
}
]
};
// motion3Json.Meta.TotalSegmentCount = obj.ParameterIds * 10
// motion3Json.Meta.TotalSegmentCount = obj.ParameterIds * 15
let TotalSegmentCount = 0
let maxTime = 0.0
for (let i = 0; i < obj.ParameterCurves.length; i++) {
let Segments = []
for (let j = 0; j < obj.ParameterCurves[i].m_Curve.length; j++) {
TotalSegmentCount++;
Segments.push(obj.ParameterCurves[i].m_Curve[j].time ?? 0)
Segments.push(obj.ParameterCurves[i].m_Curve[j].value ?? 0)
Segments.push(obj.ParameterCurves[i].m_Curve[j].weightedMode ?? 0)
maxTime = maxTime > obj.ParameterCurves[i].m_Curve[j].time ? maxTime : obj.ParameterCurves[i].m_Curve[j].time
}
Segments.pop()
motion3Json.Curves.push({
"Target": "Parameter",
"Id": obj.ParameterIds[i],
"Segments": Segments
})
}
motion3Json.Meta.CurveCount = obj.ParameterIds.length
motion3Json.Meta.Duration = maxTime
motion3Json.Meta.TotalSegmentCount = TotalSegmentCount
motion3Json.Meta.TotalPointCount = obj.ParameterIds.length + TotalSegmentCount
fs.writeFileSync(path.join(dirPath, `${fileName}.motion3.json`), JSON.stringify(motion3Json, '\t'));
console.log(path.join(dirPath, `${fileName}.motion3.json`) + "已生成");
} else if (CubismPhysicsControllerRexg.test(file)) {
const data = fs.readFileSync(filePath, 'utf8');
const obj = JSON.parse(data);
let physicsJson = {
"Version": 3,
"Meta": {
"PhysicsSettingCount": 0,
"TotalInputCount": 0,
"TotalOutputCount": 0,
"VertexCount": 0,
"Fps": 0,
"EffectiveForces": {
},
"PhysicsDictionary": [
]
},
"PhysicsSettings": []
}
physicsJson.Meta.EffectiveForces.Gravity = obj?._rig?.Gravity
physicsJson.Meta.EffectiveForces.Wind = obj?._rig?.Wind
physicsJson.Meta.Fps = obj._rig?.Fps ?? 60
for (let i = 0; i < obj._rig?.SubRigs?.length ?? 0; i++) {
let physicsSetting = {
"Id": "PhysicsSetting",
"Input": [
],
"Output": [
],
"Vertices": [
],
"Normalization": {
}
}
let rig = obj._rig.SubRigs[i]
physicsSetting.Id = physicsSetting.Id + (i + 1)
physicsJson.Meta.PhysicsDictionary.push({
"Id": physicsSetting.Id,
"Name": i + 1 + ""
})
for (let j = 0; j < rig?.Input.length ?? 0; j++) {
physicsSetting.Input.push({
"Source": {
"Target": "Parameter",
"Id": rig.Input[j].SourceId
},
"Weight": rig.Input[j].Weight,
"Type": rig.Input[j].AngleScale || rig.Input[j].AngleScale === 0 ? "Angle" : "X",
"Reflect": false
})
}
for (let j = 0; j < rig?.Output.length ?? 0; j++) {
physicsSetting.Output.push({
"Destination": {
"Target": "Parameter",
"Id": rig.Output[j].DestinationId
},
"VertexIndex": 1,
"Scale": rig.Output[j].AngleScale ?? 1,
"Weight": rig.Output[j].Weight,
"Type": rig.Output[j].AngleScale || rig.Output[j].AngleScale === 0 ? "Angle" : "X",
"Reflect": false
})
}
for (let j = 0; j < rig?.Particles?.length; j++) {
physicsSetting.Vertices.push({
"Position": rig?.Particles[j].InitialPosition,
"Mobility": rig?.Particles[j].Mobility,
"Delay": rig?.Particles[j].Delay,
"Acceleration": rig?.Particles[j].Acceleration,
"Radius": rig?.Particles[j].Radius
})
}
physicsSetting.Normalization = rig.Normalization
physicsJson.PhysicsSettings.push(physicsSetting)
}
fs.writeFileSync(path.join(dirPath, `l2d${Math.ceil(Math.random() * 100000)}.physics3.json`), JSON.stringify(physicsJson, '\t'));
console.log(path.join(dirPath, `physics3.json`) + "已生成");
} else if (file.endsWith(".anim")) {
try {
const animTxt = fs.readFileSync(filePath, 'utf8');
let lines = animTxt.split("\n");
// console.log(lines[0]);
const animJson = hangle(lines, 3, 0).value
const motion3Json = {
'Version': 3,
"Meta": {
"Duration": 0.000,
"Fps": 60.0,
"Loop": true,
"AreBeziersRestricted": true,
"CurveCount": 0,
"TotalSegmentCount": 0,
"TotalPointCount": 0,
"UserDataCount": 1,
"TotalUserDataSize": 0
},
"Curves": [],
"UserData": [
{
"Time": 0.0,
"Value": ""
}
]
};
const fileName = path.basename(file, '.anim');
let TotalSegmentCount = 0
for (let i = 0; i < animJson.AnimationClip.m_FloatCurves.length ?? 0; i++) {
let Segments = []
let curve = animJson.AnimationClip.m_FloatCurves[i].curve
for (let j = 0; j < curve.m_Curve.length ?? 0; j++) {
Segments.push(curve.m_Curve[j].time)
Segments.push(curve.m_Curve[j].value)
Segments.push(0)
}
Segments.pop()
TotalSegmentCount += curve.m_Curve.length
if (Object.prototype.toString.call(animJson.AnimationClip.m_FloatCurves[i].path) === "[object String]") {
// let Target, Id = animJson.AnimationClip.m_FloatCurves[i].path?.split("/")
motion3Json.Curves.push({
"Target": "Parameter",
"Id": animJson.AnimationClip.m_FloatCurves[i].path?.split("/")[1],
"Segments": Segments
})
}
}
motion3Json.Meta.Duration = animJson.AnimationClip.m_AnimationClipSettings.m_StopTime - animJson.AnimationClip.m_AnimationClipSettings.m_StartTime
motion3Json.Meta.TotalSegmentCount = TotalSegmentCount
motion3Json.Meta.CurveCount = animJson.AnimationClip.m_FloatCurves.length
motion3Json.Meta.TotalPointCount = motion3Json.Meta.TotalSegmentCount + motion3Json.Meta.CurveCount
fs.writeFileSync(path.join(dirPath, `${fileName}.motion3.json`), JSON.stringify(motion3Json, '\t'))
console.log(`${fileName}.motion3.json已生成`);
}catch(err) {
console.log(err);
console.log(file + "转化失败");
}
}
}
}
processFadeFiles("tianshu");
每个角色的live2d,只有三个motion,以 角色名+@Idlexx 来命名,如下图天书的例子
然后就是写一个model3.json文件来加载,我这里拿其他游戏直接套的,也可以写个脚本来自动套
{
"Version": 3,
"FileReferences": {
"Moc": "tianshu_model.moc3",
"Textures": [
"textures/texture_1014.png"
],
"Motions": {
"Idle": [
{
"File": "motions/[email protected]",
"FadeInTime": 0,
"FadeOutTime": 0
}
],
"Tap": [
{
"File": "motions/[email protected]"
},
{
"File": "motions/[email protected]"
}
]
}
},
"HitAreas": []
}
3.结尾
至此应该就差不多了,和 muyi123 佬一起拼了20多个,这里感谢回帖的两个佬,还有QQ帮忙认角色的兄弟,后面 muyi123 佬上传了Live2DViewerEX的工坊,还对着B站的视频用vits切了语音(肝帝),大家可以去工坊订阅去支持他
这里用B的视频是因为包里面的音频资源拆出来不全,而且我们只有一个安装包的资源,不知道是不是缺了,大家可以看看自己的游戏资源有多大,如果有大佬愿意提供完整资源的话真的不胜感激
研究过程中用到的全部资源都在这了,感兴趣的可以自己下来研究
- 提取的Spine: Spine.tar.gz - Google ドライブ
- 导出的音频: Sound.tar.gz - Google ドライブ
- 用到的脚本原档: 一些脚本 - Google ドライブ
- 拆的图包: 解神者图包.tar.gz - Google ドライブ
- 安装包(B服) : 解神者_2.4.apk - Google ドライブ
- AnimationClip: AnimationClip.tar.gz - Google ドライブ
- MonoBehaviour: MonoBehaviour.tar.gz - Google ドライブ
最后的最后忘了说这游戏是live2d+spine混杂(
扔一个我自己用来分类的脚本,as选择TextAssets和Textures,导出后直接用这个脚本就能自动分类+贴图分辨率修正
#!/bin/bash
# 遍历TextAsset文件夹中的文件
for file in ./TextAsset/*; do
# 检查文件名是否包含".atlas"或".skel"
if [[ $file == *".atlas"* || $file == *".skel"* ]]; then
# 提取文件名和扩展名
base_name=$(basename "$file")
new_name=""
# 如果文件名包含".atlas",只保留".atlas"作为后缀
if [[ $base_name == *".atlas"* ]]; then
new_name="${base_name%%.atlas*}.atlas"
fi
# 如果文件名包含".skel",只保留".skel"作为后缀
if [[ $base_name == *".skel"* ]]; then
new_name="${base_name%%.skel*}.skel"
fi
# 如果新文件名与旧文件名不同,进行重命名
if [ "$base_name" != "$new_name" ]; then
mv "$file" "./TextAsset/$new_name"
echo "重命名文件: $file 为 $new_name"
else
echo "跳过文件: $file,不需要重命名"
fi
fi
done
# 创建Spine文件夹(如果不存在)
mkdir -p ./Spine
# 遍历TextAsset文件夹中的以.atlas为后缀的文本文件
for atlas_file in ./TextAsset/*.atlas; do
# 提取文件名(不包括扩展名)
atlas_base_name=$(basename -s .atlas "$atlas_file")
# 检查是否存在对应的.png和size数据
png_files=()
size_data="./TextAsset/$atlas_base_name.atlas"
if [ -f "$size_data" ]; then
while IFS= read -r line; do
if [[ $line == *".png" ]]; then
png_file="./Texture2D/$line"
if [ -f "$png_file" ]; then
png_files+=("$png_file")
fi
fi
done < "$size_data"
if [ "${#png_files[@]}" -gt 0 ]; then
# 从size数据中提取分辨率信息
resolution=$(grep -oP 'size: \K[\d,]+' "$size_data")
# 移动.atlas文件、对应的.png文件和匹配的.skel文件到Spine文件夹下的匹配文件夹
target_folder="./Spine/$atlas_base_name"
mkdir -p "$target_folder"
cp "$atlas_file" "$target_folder"
for png_file in "${png_files[@]}"; do
cp "$png_file" "$target_folder"
done
cp "./TextAsset/$atlas_base_name.skel"* "$target_folder"
echo "复制文件到 $target_folder"
else
echo "跳过 $atlas_file,没有找到对应的.png文件"
fi
else
echo "跳过 $atlas_file,缺少对应的size数据文件"
fi
done
# 处理图片缩放,确保与atlas中的一致
python <<EOF
import os
import re
from PIL import Image
def resize_image(image_path, new_size, output_path):
image = Image.open(image_path)
resized_image = image.resize(new_size, Image.LANCZOS) # LANCZOS插值
resized_image.save(output_path)
spine_folder = "Spine" # 改为你的Spine文件夹路径
atlas_files = []
for root, dirs, files in os.walk(spine_folder):
for file in files:
if file.endswith(".atlas"):
atlas_files.append(os.path.join(root, file))
for atlas_file in atlas_files:
with open(atlas_file, "r") as file:
lines = file.readlines()
current_image = None
correct_size = None
image_pattern = re.compile(r'([^#]+)\.png')
size_pattern = re.compile(r'size:\s*(\d+),\s*(\d+)')
for line in lines:
image_match = image_pattern.search(line)
size_match = size_pattern.search(line)
if image_match:
current_image = image_match.group(1) + ".png"
elif size_match:
if current_image and not correct_size:
width, height = map(int, size_match.groups())
correct_size = (width, height)
elif current_image and correct_size:
width, height = map(int, size_match.groups())
if (width, height) != correct_size:
image_path = os.path.join(os.path.dirname(atlas_file), current_image)
if Image.open(image_path).size != correct_size:
print(f"Resizing {image_path} to {correct_size}...")
resize_image(image_path, correct_size, image_path)
current_image = None
correct_size = None
print("操作完成。")
EOF