嗨,大傢伙,我是新發。
有同學私信並給我發了封郵件,內容如下:
郵件內容:
林新發大哥你好,我叫**,是個四川98年的小夥,因為從小在山寨機上玩武俠網遊,悠米遊戲平臺的天龍傳奇,尋秦OL,冒泡平臺降龍十八掌,笑傲江湖,傲劍ol等遊戲,玩了很多遊戲,最喜歡的還是天龍傳奇和尋秦OL這2款 武俠回合制。
後來學了計算機應用,然後混到了畢業,被中介坑到天津當了1年5G督導,後來畢業很迷茫,最後貸款學了Unity,非常遺憾,學完後找了一個公司開發了3個月的益智類遊戲,每天都很忙,但是並沒有任何進步,然後我就明白了,有些東西不適合,它就是不適合,我每天寫程式碼幾乎都是 Transform 過去過來,我也知道全是淺顯的東西,但是這淺顯的東西我都需要花很久才能明白,每天都很煎熬。
後來轉行快遞行業,每天除了場地上的電腦硬體問題,這才感到學有所用,雖然有時也會覺得程式設計師前途很好,廠裡面修電腦就是混日子,但是不會像以前那麼煎熬了,或許我內心還是在給自己不努力找藉口。
然後就是空閒時間老是想起這個小時候的遊戲,知道有人用愛發電在復刻一直在期待,將近500多個人在期待,經過無數所謂的眾籌請人開發,群友自己花錢找工作室開發(到規定時間他就說工作室出問題,2次後大家才明白他在和幾百人開玩笑),各種被鴿之後,終於明白這個遊戲不可能回來的了。
然後就想自己拿素材做單機小遊戲,尋找一下回憶,但是能力有限,連一個檔案的讀取 資料的轉換都弄不明白,最後問了幾個人,也找同學弄了一下,還是不行,主要原因還是自己程式設計能力不足,最後經過忐忑的心情給你發了私信。
就是說,想在Unity
中逆向尋秦OL
的資源(序列幀動畫),並可以在Unity
中播放。
遺憾的是我小時候沒玩過這個遊戲,只看過尋秦記電視劇,還是小時候的電視劇好看呀,現在都很少看電視劇了。
嘛,話說回來,我還是先解決一下這個同學的問題,講講如何對二進位制資源進行解析並逆向生成Unity
預設檔案。
本文最終效果如下
工程原始碼見文章末尾。
郵件中發了一些資原始檔,是二進位制格式的,包括.pwd
、.aef
檔案等,
很多遊戲都會自己構造二進位制資原始檔,目的有兩個:
1、加大逆向的難度;
2、壓縮資源大小。
我們如果只拿到了二進位制資原始檔,是比較難逆推出裡面的具體內容的,一般還需要配合逆向遊戲程式碼,通過程式碼的解析邏輯去逆推資源的資料格式,然後再寫工具去把資源解析出來儲存為我們可以用的資源格式。
所幸,郵件中提到有人已經整理了這些格式(.pwd
、.aef
、.mape
)的資料規則,省去了我去逆向程式碼的過程,下面就先說明一下這些檔案的資料格式吧~
pwd
檔案,它是素材檔案,本質上是png
加一些自定義資料,自帶分割png
的資料。
資料格式如下:
長度 | 含義 |
---|---|
2位元組 | 當前檔案的ID |
4位元組 | 圖片資源長度 |
前一個欄位的值的位元組數 | 圖片資源 |
2位元組 | 圖片可被分成的小圖數量 |
再往後迴圈讀取以下欄位,迴圈次數是圖片可被分成的小圖數量,
長度 | 含義 |
---|---|
2位元組 | 座標x |
2位元組 | 座標y |
2位元組 | 小圖寬度width |
2位元組 | 小圖高度height |
畫個圖方便大家理解,
上面的pwd
檔案可以理解為是圖集檔案,而這裡要講的aef
檔案可以理解為序列幀動畫檔案
,aef
記錄了每一幀使用的小圖檔案和座標資訊等。
資料格式如下:
長度 | 含義 |
---|---|
2位元組 | 該檔案包含的幀數量 |
後面的資料連續迴圈上面欄位的值,每次迴圈讀取以下的欄位
長度 | 含義 |
---|---|
2位元組 | 幀ID |
4位元組 | 該幀用到的小圖數量 |
然後根據該幀用到的小圖數量回圈讀取以下的欄位
長度 | 含義 |
---|---|
2位元組 | pwd檔案的ID |
2位元組 | 當前圖片的ID |
2位元組 | 座標x |
2位元組 | 座標y |
畫個圖方便大家理解,
我們要在Unity
中去解析pwd
和aef
檔案,就要用到讀取二進位制檔案的API
,有必要單獨拿出來講一下。
我們要開啟一個二進位制檔案,可以使用FileStream
類,需要引入名稱空間:
using System.IO;
使用方法:
string filePath = "要開啟的檔案路徑";
using (FileStream fs = new FileStream(filePath , FileMode.Open))
{
// TODO 檔案流操作
}
上面我們是通過FileStream
自身的建構函式來構建一個FileStream
物件的,我們也可以通過File.Open
來構建FileStream
物件,如下
string filePath = "要開啟的檔案路徑";
using(var fs = File.Open(filePath, FileMode.Open))
{
// TODO 檔案流操作
}
注:可能有同學會問,這個
using
是幹嘛的?
我們把建立的檔案流物件的過程寫在using
中,在離開using
作用域時會自動幫助我們釋放流所佔用的資源,否則我們需要手動呼叫FileStream
的Dispose
方法來釋放資源。
上面我們得到FileStream
物件,接下來就可以使用BinaryReader
來對流進行二進位制讀取了,例:
string filePath = "要開啟的檔案路徑";
using (FileStream fs = new FileStream(filePath , FileMode.Open))
{
using (BinaryReader br = new BinaryReader(fs))
{
// 讀取1個位元組
byte a0 = br.ReadByte();
// 讀取2個位元組,並以小端位元組序轉為short,需要特別小心!
short a1 = br.ReadInt16();
// 讀取4個位元組,並以小端位元組序轉為int,需要特別小心!
int a2 = br.ReadInt32();
// 讀取800個位元組
byte[] a3 = br.ReadBytes(800);
}
}
上面程式碼中ReadInt16
和ReadInt32
需要特別小心位元組序問題,什麼是位元組序呢?為什麼要搞位元組序這個東西呢?我來給你講清楚。
我們的計算機記憶體是以位元組為儲存單元的,畫個圖,
我們知道,一個short
是2個位元組
,一個int
是4個位元組
,現在我問你,假設用0x00000000
和0x00000001
這兩個地址對應的2個位元組
來表示一個short
,那麼這個short
的值是多少?
你可能會回答0x1C09
,因為低地址是0x09
,高地址是0x1C
,組合起來就是0x1C09
,轉為十進位制就是7177
,
但是,為什麼不能是0x091C
呢?誰規定高地址就是高位,低地址就一定是低位呢?
這個,就是位元組序問題。
如果是高地址放高位,低地址放低位,就是小端位元組序
,這個符合我們人類的思維習慣。(口訣:高高低低為小端)。
反過來就是大端位元組序
。雖然說小端位元組序符合人類的思維習慣,但卻反而不直觀,為什麼?比如下面這個二進位制檔案,我圈出來的4個位元組
的值你是不是第一反應是0x00000065
(大端位元組序),如果你真按小端位元組序來思考的話,應該是0x65000000
,因為0x65
的地址是最高的,按小端位元組序的話0x65
是放在最高位。不過,這裡的二進位制檔案是按大端位元組序儲存的,所以答案是0x00000065
。
現在問題又來了,我們如果使用BinaryReader
的ReadInt32()
方法一次性讀取4位元組
,它是以什麼位元組序去構造一個int
的呢?C#
預設的位元組序是小端位元組序,所以如果你用ReadInt32()
會得出錯誤的答案。
那我們如何正確的讀取這4個位元組
呢?可以先使用ReadBytes(4)
方法讀取四個位元組:
// 讀取4個位元組
byte[] intBytes = br.ReadBytes(4);
這個時候讀出來的位元組資料是這樣的
我們使用Array.Reverse
方法對資料進行反序,
Array.Reverse(intBytes );
反序後變成這樣
此時我們在使用BitConverter.ToInt32
方法即可得到正確的值0x00000065
啦(即十進位制的101
),
int i = BitConverter.ToInt32(intBytes, 0);
// i的值為0x00000065,即即十進位制的101
畫個圖總結一下,
接下來我們就來實戰吧,使用C#
的二進位制讀取的API
來解析尋秦OL
的二進位制資原始檔並生成Unity
可用的資源。
Unity
工程名就叫UnityXunqinOL
吧~
把NPC
的pwd
和aef
匯入工程目錄中,比如匯入10002
這隻怪的資原始檔,
如下
我一般是使用VS Code
碼程式碼,想要使用VS Code
檢視二進位制檔案,可以安裝Hex Editor
外掛,
安裝完畢後,點選你要檢視的檔案,然後點選Do you want to open it anyway
,
然後點選Hex Editor
,
這樣我們就可以以十六進位制的方式檢視這個二進位制檔案了,
現在我們根據上文中講的pwd
檔案的資料格式來分析一下。
前2個位元組
是檔案ID
,可見10002_1.pwd
的檔案ID
是0
,
接下來是4個位元組
,表示png
資料長度,為0x000006F5
,轉為十進位制即1781
位元組,
我們推算一下,讀完這1781
個位元組,就到了2 + 4 + 1781 - 1
的位置(注意位元組從0
位元組數起,所以這裡減1
),即第1786
位元組的位置,轉為十六進位制就是0x000006FA
的位置,我們跳到這裡,
再往下2個位元組
是小圖數量,為0x0013
,即有19
張小圖,
再往後就是解析這19
張小圖了,以第一張小圖為例,可以得出第一張小圖的座標為:x: 0x0000,y: 0x0011
,即:x: 0,y: 17
,寬高為:0x0015 0x0011
,即寬高為:21 x 17
,
後面以此類推。
現在,我們來寫工具指令碼,讓它去讀取pwd
檔案吧。
新建Editor
資料夾,
新建一個C#
指令碼,重新命名為FileReader
,如下,
先定義資料結構
// pwd資料結構
public struct PWDInfo
{
public short id; // pwd檔案id
public int pngLen; // png資料長度
public byte[] png; // png資料
public int splitCnt; // 小圖數量
public SpriteInfo[] spriteInfoList; // 小圖資訊陣列
}
// 小圖資料結構
public struct SpriteInfo
{
public int index; // 小圖索引
public int x; // 座標x
public int y; // 座標y
public int width; // 寬度
public int height; // 高度
}
封裝兩個Read
方法,裡面實現位元組反序,解決大小端問題,
/// <summary>
/// 讀取2位元組
/// </summary>
private static Int16 ReadInt16(BinaryReader br)
{
byte[] bytes = br.ReadBytes(2);
// 反位元組序
Array.Reverse(bytes);
return BitConverter.ToInt16(bytes, 0);
}
/// <summary>
/// 讀取4位元組
/// </summary>
private static Int32 ReadInt32(BinaryReader br)
{
byte[] bytes = br.ReadBytes(4);
// 反位元組序
Array.Reverse(bytes);
return BitConverter.ToInt32(bytes, 0);
}
最後封裝一個ReadPWD
方法,只需傳入pwd
檔案路徑,即可解析並返回一個PWDInfo
物件,
public static PWDInfo ReadPWD(string pwdFilePath)
{
PWDInfo pwdInfo = new PWDInfo();
using (FileStream fs = new FileStream(pwdFilePath, FileMode.Open))
{
using (BinaryReader br = new BinaryReader(fs))
{
pwdInfo.id = ReadInt16(br);
pwdInfo.pngLen = ReadInt32(br);
// PNG檔案資源
pwdInfo.png = br.ReadBytes(pwdInfo.pngLen);
// 切片數量
int spriteCnt = ReadInt16(br);
SpriteInfo[] spriteInfoList = new SpriteInfo[spriteCnt];
for (int i = 0; i < spriteCnt; ++i)
{
// 每個切片的資訊
SpriteInfo spriteInfo = new SpriteInfo();
spriteInfo.index = i;
spriteInfo.x = ReadInt16(br);
spriteInfo.y = ReadInt16(br);
spriteInfo.width = ReadInt16(br);
spriteInfo.height = ReadInt16(br);
spriteInfoList[i] = spriteInfo;
}
pwdInfo.spriteInfoList = spriteInfoList;
}
}
return pwdInfo;
}
我們再建立GenResTools
指令碼,
由它來暴露一個選單項,去呼叫FileReader.ReadPWD
,
[MenuItem("工具/通過PWD生成PNG")]
public static void GeneratePngByPWD()
{
// 掃描PWD檔案
var pwdFilePaths = Directory.GetFiles(Application.dataPath + "/NPC/", "*.pwd", SearchOption.AllDirectories);
foreach (var pwdFilePath in pwdFilePaths)
{
// 解析PWD檔案
PWDInfo pwdInfo = FileReader.ReadPWD(pwdFilePath);
// TODO 根據PWDInfo生成png圖片
}
}
我們要根據PWDInfo
生成png
圖片。
我們封裝一個儲存png
圖片的方法,
// GenResTools.cs
/// <summary>
/// 儲存圖片
/// </summary>
private static void SavePng(string savePath, byte[] data)
{
if (File.Exists(savePath))
{
File.Delete(savePath);
}
File.WriteAllBytes(savePath, data);
AssetDatabase.Refresh();
}
圖片儲存後,需要設定圖片的屬性,比如圖片格式設定為Sprite
,過濾模式設定為Point
等,我們封裝一個方法來自動完成這些設定,
// GenResTools.cs
/// <summary>
/// 自動設定圖集圖片格式
/// </summary>
private static void FixSettings(string pngPath)
{
pngPath = pngPath.Replace('\\', '/');
var assetsPath = pngPath.Replace(Application.dataPath, "Assets");
TextureImporter textureImporter = AssetImporter.GetAtPath(assetsPath) as TextureImporter;
textureImporter.textureType = TextureImporterType.Sprite;
textureImporter.spriteImportMode = SpriteImportMode.Single;
textureImporter.wrapMode = TextureWrapMode.Clamp;
textureImporter.filterMode = FilterMode.Point;
textureImporter.isReadable = true;
AssetDatabase.ImportAsset(assetsPath);
AssetDatabase.Refresh();
}
另外,我們還需要根據圖集生成精靈小圖,再封裝一個生成方法,
/// <summary>
/// 從圖集中生成精靈圖
/// </summary>
private static void GenSprites(string pwdDir, string atlasPath, PWDInfo pwdInfo)
{
atlasPath = atlasPath.Replace('\\', '/');
var assetsPath = atlasPath.Replace(Application.dataPath, "Assets");
var atlasTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(assetsPath);
foreach (SpriteInfo spriteInfo in pwdInfo.spriteInfoList)
{
// 精靈圖
var spriteName = Path.GetFileNameWithoutExtension(atlasPath) + "_" + spriteInfo.index + ".png";
var spriteSaveDir = pwdDir + "/sprites/";
if (!Directory.Exists(spriteSaveDir))
{
Directory.CreateDirectory(spriteSaveDir);
}
var spriteSavePath = spriteSaveDir + spriteName;
var spriteTexture = new Texture2D(spriteInfo.width, spriteInfo.height, TextureFormat.RGBA32, false);
for (int y = 0; y < spriteInfo.height; ++y)
{
for (int x = 0; x < spriteInfo.width; ++x)
{
var color = atlasTexture.GetPixel(spriteInfo.x + x, atlasTexture.height - spriteInfo.y - y - 1);
spriteTexture.SetPixel(x, spriteInfo.height - y - 1, color);
}
}
SavePng(spriteSavePath, spriteTexture.EncodeToPNG());
AssetDatabase.Refresh();
FixSettings(spriteSavePath);
}
AssetDatabase.Refresh();
}
這裡要注意座標系的差異,他們是使用
2D
引擎製作的尋秦OL
,使用的座標系是y
軸朝下的,與Unity
的y
軸方向是相反的,所以讀取畫素的時候要使用高度減去y
軸座標。
我們完善一下GeneratePngByPWD
方法的邏輯,最終如下,
[MenuItem("工具/通過PWD生成PNG")]
public static void GeneratePngByPWD()
{
// 掃描PWD檔案
var pwdFilePaths = Directory.GetFiles(Application.dataPath + "/NPC/", "*.pwd", SearchOption.AllDirectories);
foreach (var pwdFilePath in pwdFilePaths)
{
// 解析PWD檔案
PWDInfo pwdInfo = FileReader.ReadPWD(pwdFilePath);
var pwdDir = Path.GetDirectoryName(pwdFilePath);
var atlasName = Path.GetFileNameWithoutExtension(pwdFilePath) + ".png";
var atlasDir = pwdDir + "/atlas/";
if (!Directory.Exists(atlasDir))
{
// 在pwd所在目錄中建立atlas資料夾
Directory.CreateDirectory(atlasDir);
}
var atlasPath = Path.Combine(atlasDir, atlasName);
// 儲存圖片(圖集)
SavePng(atlasPath, pwdInfo.png);
// 設定
FixSettings(atlasPath);
// 生成精靈圖
GenSprites(pwdDir, atlasPath, pwdInfo);
}
}
點選選單工具 / 通過PWD生成PNG
,如下,可以看到正常生成了圖集和精靈小圖,
生成的圖集檔案如下,
我們可以看到,10002_1
圖集生成的小圖有19
張,與我們上文的分析結果一致,
接下來就是解析aef
檔案,然後去組織這些精靈小圖,把它們包裝成序列幀。
我們先定義AEFInfo
相關的資料結構,如下
// FileReader.cs
public struct AEFInfo
{
// 幀數
public int frameCnt;
public FrameInfo[] frameInfo;
}
public struct FrameInfo
{
public int frameId;
public int pngCnt;
public FrameSpriteInfo[] frameSpriteInfo;
}
public struct FrameSpriteInfo
{
public int pwdId;
public int spriteId;
public float x;
public float y;
}
接著,我們封裝一個ReadAEF
方法,去解析aef
檔案,並返回AEFInfo
物件,
public static AEFInfo ReadAEF(string aefFilePath)
{
AEFInfo aefInfo = new AEFInfo();
using (FileStream fs = new FileStream(aefFilePath, FileMode.Open))
{
using (BinaryReader br = new BinaryReader(fs))
{
aefInfo.frameCnt = ReadInt16(br);
aefInfo.frameInfo = new FrameInfo[aefInfo.frameCnt];
for (int i = 0; i < aefInfo.frameCnt; ++i)
{
FrameInfo frameInfo = new FrameInfo();
// 跳過檔案中的frameId,自行使用i作為frameId
br.ReadInt16();
frameInfo.frameId = i;
frameInfo.pngCnt = ReadInt32(br);
frameInfo.frameSpriteInfo = new FrameSpriteInfo[frameInfo.pngCnt];
for (int j = 0; j < frameInfo.pngCnt; ++j)
{
FrameSpriteInfo spriteInfo = new FrameSpriteInfo();
spriteInfo.pwdId = ReadInt16(br) + 1;
spriteInfo.spriteId = ReadInt16(br) - 1;
spriteInfo.x = ReadInt16(br)/100f;
spriteInfo.y = 1 - ReadInt16(br)/100f;
frameInfo.frameSpriteInfo[j] = spriteInfo;
}
aefInfo.frameInfo[i] = frameInfo;
}
}
}
return aefInfo;
}
這裡需要注意,我們是使用
SpriteRenderer
元件來渲染影象,世界空間下的座標是畫素座標的100
倍,所以這裡算座標的時候除以100f
。
最後,我們封裝一個GeneratePreabByAEF
,去掃描aef
檔案,呼叫FileReader.ReadAEF
,得到AEFInfo
物件,再根據AEFInfo
物件去生成預設檔案,如下
[MenuItem("工具/通過AEF生成預設")]
public static void GeneratePreabByAEF()
{
// 掃描AEF檔案
var aefFilePaths = Directory.GetFiles(Application.dataPath + "/NPC/", "*.aef", SearchOption.AllDirectories);
foreach (var aefFilePath in aefFilePaths)
{
// 解析AEF檔案
AEFInfo aefInfo = FileReader.ReadAEF(aefFilePath);
// 根據AEF資訊生成動畫預設檔案
SaveAniPrefab(aefFilePath, aefInfo);
}
}
其中,生成預設的方法SaveAniPrefab
如下,原理就是動態生成GameObject
,動態掛指令碼,設定成員,最後使用PrefabUtility.SaveAsPrefabAsset
方法把GameObject
儲存為預設,
/// <summary>
/// 根據AEF資訊生成動畫預設檔案
/// </summary>
private static void SaveAniPrefab(string aefFile, AEFInfo aefInfo)
{
// 字首
var aefName = Path.GetFileNameWithoutExtension(aefFile);
var prefix = aefName.Substring(0, aefName.IndexOf("_"));
var eafDir = Path.GetDirectoryName(aefFile);
var spriteDir = eafDir.Replace('\\', '/') + "/sprites/";
var spriteAssetDir = spriteDir.Replace(Application.dataPath, "Assets/");
var aniObj = new GameObject("ani_" + aefName);
var aniRuntime = aniObj.AddComponent<AniRuntime>();
aniRuntime.frameObjs = new GameObject[aefInfo.frameCnt];
foreach (var frame in aefInfo.frameInfo)
{
// 建立幀
var frameObj = new GameObject("frame_" + frame.frameId);
frameObj.transform.SetParent(aniObj.transform, false);
foreach (var spriteInfo in frame.frameSpriteInfo)
{
// 一幀可能由多張圖片組成,這裡取去生成一幀中的圖片
var spriteObj = new GameObject("sprite_" + spriteInfo.spriteId);
var renderer = spriteObj.AddComponent<SpriteRenderer>();
var sprPath = spriteAssetDir + prefix + "_" + spriteInfo.pwdId + "_" + spriteInfo.spriteId + ".png";
var spriteRes = AssetDatabase.LoadAssetAtPath<Sprite>(sprPath);
if (null == spriteRes)
{
Debug.LogError("缺少資源:" + sprPath + "\n請檢查PWD檔案生成PNG的步驟是否正常");
}
renderer.sprite = spriteRes;
spriteObj.transform.SetParent(frameObj.transform, false);
spriteObj.transform.localPosition = new Vector3(spriteInfo.x, spriteInfo.y, 0);
}
if (frame.frameId >= 0 && frame.frameId < aefInfo.frameCnt)
aniRuntime.frameObjs[frame.frameId] = frameObj;
else
Debug.LogError("Illegal frameId: " + frame.frameId);
frameObj.SetActive(frame.frameId == 0);
}
aniObj.transform.localPosition = new Vector3(0, -6.5f, 0);
aniObj.transform.localScale = Vector3.one * 5;
// 生成預設
var prefabDir = Application.dataPath + "/Prefabs/";
if (!Directory.Exists(prefabDir))
{
Directory.CreateDirectory(prefabDir);
}
prefabDir = prefabDir.Replace(Application.dataPath, "Assets/");
PrefabUtility.SaveAsPrefabAsset(aniObj, prefabDir + aniObj.name + ".prefab");
GameObject.DestroyImmediate(aniObj);
}
建立一個AniRuntime.cs
指令碼,用於執行時執行序列幀的顯示,
這裡我只是簡單的對序列幀進行隱藏和啟用,純粹作為演示,實際專案中不建議這麼做,
using UnityEngine;
public class AniRuntime : MonoBehaviour
{
[SerializeField]
public GameObject[] frameObjs;
public float frameInterval = 0.1f;
private float timer;
private int curFrame;
void Update()
{
timer += Time.deltaTime;
if (timer >= frameInterval)
{
timer = 0;
++curFrame;
if (curFrame >= frameObjs.Length)
{
curFrame = 0;
}
for (int i = 0; i < frameObjs.Length; ++i)
{
if(null != frameObjs[i])
frameObjs[i].SetActive(curFrame == i);
}
}
}
}
點選選單工具 / 通過AEF生成預設
,生成預設檔案,如下,
生成的預設檔案的子節點是按幀來分組的,
一幀裡面有n
張小圖,如下,
我們把預設拖到場景中,執行Unity
,效果如下,
我們丟一些其他怪物的pwd
和aef
檔案到工程中,生成預設,執行預覽效果如下,
本文工程我已上傳到GitCode
,感興趣的同學可自行下載學習,
地址:https://gitcode.net/linxinfa/UnityXunqinOL
注:我使用的Unity版本是2021.1.7.f1c1
好了,就寫到這裡吧。
我是新發,https://blog.csdn.net/linxinfa
一個在小公司默默奮鬥的Unity
開發者,希望可以幫助更多想學Unity
的人,共勉~