【遊戲開發實戰】Unity逆向懷舊經典遊戲《尋秦OL》,解析二進位制動畫檔案生成預設並播放(資源逆向 | 二進位制 | C#)

2022-01-04 21:00:02

一、前言

嗨,大傢伙,我是新發。
有同學私信並給我發了封郵件,內容如下:

郵件內容:
林新發大哥你好,我叫**,是個四川98年的小夥,因為從小在山寨機上玩武俠網遊,悠米遊戲平臺的天龍傳奇,尋秦OL,冒泡平臺降龍十八掌,笑傲江湖,傲劍ol等遊戲,玩了很多遊戲,最喜歡的還是天龍傳奇和尋秦OL這2款 武俠回合制。
後來學了計算機應用,然後混到了畢業,被中介坑到天津當了1年5G督導,後來畢業很迷茫,最後貸款學了Unity,非常遺憾,學完後找了一個公司開發了3個月的益智類遊戲,每天都很忙,但是並沒有任何進步,然後我就明白了,有些東西不適合,它就是不適合,我每天寫程式碼幾乎都是 Transform 過去過來,我也知道全是淺顯的東西,但是這淺顯的東西我都需要花很久才能明白,每天都很煎熬。
後來轉行快遞行業,每天除了場地上的電腦硬體問題,這才感到學有所用,雖然有時也會覺得程式設計師前途很好,廠裡面修電腦就是混日子,但是不會像以前那麼煎熬了,或許我內心還是在給自己不努力找藉口。
然後就是空閒時間老是想起這個小時候的遊戲,知道有人用愛發電在復刻一直在期待,將近500多個人在期待,經過無數所謂的眾籌請人開發,群友自己花錢找工作室開發(到規定時間他就說工作室出問題,2次後大家才明白他在和幾百人開玩笑),各種被鴿之後,終於明白這個遊戲不可能回來的了。
然後就想自己拿素材做單機小遊戲,尋找一下回憶,但是能力有限,連一個檔案的讀取 資料的轉換都弄不明白,最後問了幾個人,也找同學弄了一下,還是不行,主要原因還是自己程式設計能力不足,最後經過忐忑的心情給你發了私信。

就是說,想在Unity中逆向尋秦OL的資源(序列幀動畫),並可以在Unity中播放。
遺憾的是我小時候沒玩過這個遊戲,只看過尋秦記電視劇,還是小時候的電視劇好看呀,現在都很少看電視劇了。
嘛,話說回來,我還是先解決一下這個同學的問題,講講如何對二進位制資源進行解析並逆向生成Unity預設檔案。

本文最終效果如下
請新增圖片描述
請新增圖片描述
請新增圖片描述
工程原始碼見文章末尾。

二、資原始檔說明

1、二進位制檔案(pwd檔案、aef檔案)

郵件中發了一些資原始檔,是二進位制格式的,包括.pwd.aef檔案等,
在這裡插入圖片描述

很多遊戲都會自己構造二進位制資原始檔,目的有兩個:
1、加大逆向的難度;
2、壓縮資源大小。
我們如果只拿到了二進位制資原始檔,是比較難逆推出裡面的具體內容的,一般還需要配合逆向遊戲程式碼,通過程式碼的解析邏輯去逆推資源的資料格式,然後再寫工具去把資源解析出來儲存為我們可以用的資源格式。
所幸,郵件中提到有人已經整理了這些格式(.pwd.aef.mape)的資料規則,省去了我去逆向程式碼的過程,下面就先說明一下這些檔案的資料格式吧~

2、資料格式

2.1、pwd格式

pwd檔案,它是素材檔案,本質上是png加一些自定義資料,自帶分割png的資料。
資料格式如下:

長度含義
2位元組當前檔案的ID
4位元組圖片資源長度
前一個欄位的值的位元組數圖片資源
2位元組圖片可被分成的小圖數量

再往後迴圈讀取以下欄位,迴圈次數是圖片可被分成的小圖數量,

長度含義
2位元組座標x
2位元組座標y
2位元組小圖寬度width
2位元組小圖高度height

畫個圖方便大家理解,
在這裡插入圖片描述

2.2、aef格式

上面的pwd檔案可以理解為是圖集檔案,而這裡要講的aef檔案可以理解為序列幀動畫檔案aef記錄了每一幀使用的小圖檔案和座標資訊等。

資料格式如下:

長度含義
2位元組該檔案包含的幀數量

後面的資料連續迴圈上面欄位的值,每次迴圈讀取以下的欄位

長度含義
2位元組幀ID
4位元組該幀用到的小圖數量

然後根據該幀用到的小圖數量回圈讀取以下的欄位

長度含義
2位元組pwd檔案的ID
2位元組當前圖片的ID
2位元組座標x
2位元組座標y

畫個圖方便大家理解,
在這裡插入圖片描述

三、C#讀取二進位制檔案的API

我們要在Unity中去解析pwdaef檔案,就要用到讀取二進位制檔案的API,有必要單獨拿出來講一下。

1、開啟二進位制檔案:FileStream檔案流

我們要開啟一個二進位制檔案,可以使用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作用域時會自動幫助我們釋放流所佔用的資源,否則我們需要手動呼叫FileStreamDispose方法來釋放資源。

2、二進位制讀取:BinaryReader

上面我們得到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);

	}
}

3、位元組序問題:大端小端

上面程式碼中ReadInt16ReadInt32需要特別小心位元組序問題,什麼是位元組序呢?為什麼要搞位元組序這個東西呢?我來給你講清楚。
我們的計算機記憶體是以位元組為儲存單元的,畫個圖,
在這裡插入圖片描述
我們知道,一個short2個位元組,一個int4個位元組,現在我問你,假設用0x000000000x00000001這兩個地址對應的2個位元組來表示一個short,那麼這個short的值是多少?
在這裡插入圖片描述
你可能會回答0x1C09,因為低地址是0x09,高地址是0x1C,組合起來就是0x1C09,轉為十進位制就是7177
在這裡插入圖片描述

但是,為什麼不能是0x091C呢?誰規定高地址就是高位,低地址就一定是低位呢?
這個,就是位元組序問題。
如果是高地址放高位,低地址放低位,就是小端位元組序,這個符合我們人類的思維習慣。(口訣:高高低低為小端)。
反過來就是大端位元組序。雖然說小端位元組序符合人類的思維習慣,但卻反而不直觀,為什麼?比如下面這個二進位制檔案,我圈出來的4個位元組的值你是不是第一反應是0x00000065(大端位元組序),如果你真按小端位元組序來思考的話,應該是0x65000000,因為0x65的地址是最高的,按小端位元組序的話0x65是放在最高位。不過,這裡的二進位制檔案是按大端位元組序儲存的,所以答案是0x00000065
在這裡插入圖片描述
現在問題又來了,我們如果使用BinaryReaderReadInt32()方法一次性讀取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可用的資源。

1、建立Unity工程

Unity工程名就叫UnityXunqinOL吧~
在這裡插入圖片描述

2、匯入pwd和aef檔案

NPCpwdaef匯入工程目錄中,比如匯入10002這隻怪的資原始檔,
在這裡插入圖片描述
如下
在這裡插入圖片描述

3、使用十六進位制檢視器(Hex Editor)

我一般是使用VS Code碼程式碼,想要使用VS Code檢視二進位制檔案,可以安裝Hex Editor外掛,
在這裡插入圖片描述
安裝完畢後,點選你要檢視的檔案,然後點選Do you want to open it anyway
在這裡插入圖片描述
然後點選Hex Editor
在這裡插入圖片描述
這樣我們就可以以十六進位制的方式檢視這個二進位制檔案了,
在這裡插入圖片描述

4、挨個位元組分析

現在我們根據上文中講的pwd檔案的資料格式來分析一下。
2個位元組是檔案ID,可見10002_1.pwd檔案ID0
在這裡插入圖片描述
接下來是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
在這裡插入圖片描述
後面以此類推。

5、寫工具指令碼:pwd生成png

5.1、建立FileRead指令碼

現在,我們來寫工具指令碼,讓它去讀取pwd檔案吧。
新建Editor資料夾,
在這裡插入圖片描述
新建一個C#指令碼,重新命名為FileReader,如下,
在這裡插入圖片描述

5.2、定義PWDInfo資料結構

先定義資料結構

// 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;	// 高度
}
5.3、封裝ReadInt16和ReadInt32方法

封裝兩個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);
}
5.4、封裝ReadPWD方法

最後封裝一個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;
}
5.5、建立GenResTools指令碼

我們再建立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圖片。

5.6、封裝儲存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();
}
5.7、自動設定圖片屬性

圖片儲存後,需要設定圖片的屬性,比如圖片格式設定為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();
}
5.8、生成精靈小圖

另外,我們還需要根據圖集生成精靈小圖,再封裝一個生成方法,

/// <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軸朝下的,與Unityy軸方向是相反的,所以讀取畫素的時候要使用高度減去y軸座標。

5.9、遍歷pwd檔案執行生成

我們完善一下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);
    }
}
5.10、執行選單生成png圖片

點選選單工具 / 通過PWD生成PNG,如下,可以看到正常生成了圖集和精靈小圖,
請新增圖片描述
生成的圖集檔案如下,
在這裡插入圖片描述
我們可以看到,10002_1圖集生成的小圖有19張,與我們上文的分析結果一致,
在這裡插入圖片描述

6、寫工具指令碼:aef生成預設檔案

接下來就是解析aef檔案,然後去組織這些精靈小圖,把它們包裝成序列幀。

6.1、定義AEFInfo資料結構

我們先定義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;
}
6.2、封裝ReadAEF方法

接著,我們封裝一個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

6.3、封裝GeneratePreabByAEF方法

最後,我們封裝一個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);
    }
}
6.4、封裝SaveAniPrefab方法

其中,生成預設的方法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);
}

7、編寫執行時指令碼:AniRuntime.cs

建立一個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);
            }
        }
    }
}

8、執行選單生成預設檔案

點選選單工具 / 通過AEF生成預設,生成預設檔案,如下,
請新增圖片描述
生成的預設檔案的子節點是按幀來分組的,
在這裡插入圖片描述
一幀裡面有n張小圖,如下,
請新增圖片描述

9、執行測試動畫

我們把預設拖到場景中,執行Unity,效果如下,
請新增圖片描述
我們丟一些其他怪物的pwdaef檔案到工程中,生成預設,執行預覽效果如下,
請新增圖片描述
請新增圖片描述

五、工程原始碼

本文工程我已上傳到GitCode,感興趣的同學可自行下載學習,
地址:https://gitcode.net/linxinfa/UnityXunqinOL
注:我使用的Unity版本是2021.1.7.f1c1
在這裡插入圖片描述

六、完畢

好了,就寫到這裡吧。
我是新發,https://blog.csdn.net/linxinfa
一個在小公司默默奮鬥的Unity開發者,希望可以幫助更多想學Unity的人,共勉~