方法合集——第七章
GameSave脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using System.IO;
using Google.Protobuf.Gs;
using Google.Protobuf.Collections;
using Debug = UnityEngine.Debug;public class GameSave
{private static GameSave instance = new GameSave();public static GameSave Instance => instance;public string root;public const int SaveID = 1000;public const string bake = "bake";public const string Chest_Root = "Chest_Root";public void Init(){root = Path.Combine(Application.persistentDataPath, "Save");}public void Save(long versionId, string scene){long version = 0;//找到已存档的目录 然后覆盖原有的文件if (versionId > 0){version = versionId;}else{//新建一个档案version = SaveID;// TimeHelper.Now();}var version_root = Path.Combine(root, version.ToString());if (Directory.Exists(version_root) == false){Directory.CreateDirectory(version_root);}//全局文件var v_main = Path.Combine(version_root, "main.txt");//关卡文件var v_scene = Path.Combine(version_root, $"{scene}.txt");//主角数据SaveInfoMain saveInfoMain = new SaveInfoMain();saveInfoMain.Scene = scene;saveInfoMain.Player = SavePlayer(UnitManager.Instance.player);//背包数据SetSaveInfo_BagGrild(saveInfoMain.Grilds);//写入全局数据进行保存var m_data = ProtoHelper.ToBytes(saveInfoMain);File.WriteAllBytes(v_main, m_data);//获取关卡的数据 进行保存SaveLevelInfo saveLevelInfo = new SaveLevelInfo();var npc_root = GameObject.Find("NPC");//场景NPCif (npc_root != null){var fsm = npc_root.transform.GetComponentsInChildren<FSM>(true);if (fsm != null && fsm.Length > 0){foreach (var item in fsm){saveLevelInfo.Ai.Add(item.gameObject.name, SavePlayer(item));}}}//宝箱var chest_root = GameObject.Find(Chest_Root);if (chest_root != null){ChestInfo[] chestInfos = chest_root.GetComponentsInChildren<ChestInfo>(true);if (chestInfos != null && chestInfos.Length > 0){foreach (var item in chestInfos){saveLevelInfo.Chest.Add(item.gameObject.name, new SaveChestInfo(){State = item.state});}}}//关卡剩余的挑战时间var t = GameObject.Find("Timer/T001");if (t != null){var t_com = t.GetComponent<Timer>();saveLevelInfo.Time = t_com.GetSaveInfo();}//计时器 todo..var sceneBytes = ProtoHelper.ToBytes(saveLevelInfo);File.WriteAllBytes(v_scene, sceneBytes);}public SavePlayerInfo SavePlayer(FSM fsm){SavePlayerInfo savePlayerInfo = new SavePlayerInfo();if (fsm != null && fsm.yet){savePlayerInfo.State = fsm.currentState.id;savePlayerInfo.Hp = (int)fsm.att_crn.hp;savePlayerInfo.Pos.Add(fsm._transform.position.x);savePlayerInfo.Pos.Add(fsm._transform.position.y);savePlayerInfo.Pos.Add(fsm._transform.position.z);savePlayerInfo.Rot.Add(fsm._transform.eulerAngles.x);savePlayerInfo.Rot.Add(fsm._transform.eulerAngles.y);savePlayerInfo.Rot.Add(fsm._transform.eulerAngles.z);savePlayerInfo.Active = fsm._gameObject.activeSelf;}return savePlayerInfo;}public void SetSaveInfo_BagGrild(MapField<int, SaveGrildInfo> dct){dct.Clear();foreach (var item in BagData.Instance.dct){SaveGrildInfo info = new SaveGrildInfo();info.Id = item.Key;info.PropId = item.Value.id;info.Count = item.Value.count;dct[item.Key] = info;}}public void Load()//long versionId{//var version_root = Path.Combine(root, versionId.ToString());var version_root = Path.Combine(root, SaveID.ToString());//全局文件var v_main = Path.Combine(version_root, "main.txt");var main_bytes = File.ReadAllBytes(v_main);SaveInfoMain saveInfoMain = ProtoHelper.ToObject<SaveInfoMain>(main_bytes);//关卡文件var v_scene = Path.Combine(version_root, $"{saveInfoMain.Scene}.txt");var scene_bytes = File.ReadAllBytes(v_scene);SaveLevelInfo saveLevelInfo = ProtoHelper.ToObject<SaveLevelInfo>(scene_bytes);GameSystem.Instance.SceneController.Load(saveInfoMain.Scene, false, () => {//恢复主角UnitManager.Instance.player.Recover(saveInfoMain.Player);//恢复背包BagData.Instance.Recover(saveInfoMain);//恢复场景的NPC var npc_root = GameObject.Find("NPC");//场景NPCif (npc_root != null){var fsm = npc_root.transform.GetComponentsInChildren<FSM>(true);if (fsm != null && fsm.Length > 0){foreach (var item in fsm){if (saveLevelInfo.Ai.ContainsKey(item.gameObject.name)){var data = saveLevelInfo.Ai[item.gameObject.name];item.Recover(data);}}}}//宝箱var chest_root = GameObject.Find(Chest_Root);if (chest_root != null){ChestInfo[] chestInfos = chest_root.GetComponentsInChildren<ChestInfo>(true);if (chestInfos != null && chestInfos.Length > 0){foreach (var item in chestInfos){if (saveLevelInfo.Chest.ContainsKey(item.gameObject.name)){var data = saveLevelInfo.Chest[item.gameObject.name];Debug.Log(item.gameObject.name + " " + data.State);item.Recover(data);}//saveLevelInfo.Chest.Add(item.gameObject.name, new SaveChestInfo()//{// State = item.state//});}}}//剩余的关卡时间//关卡剩余的挑战时间var t = GameObject.Find("Timer/T001");if (t != null){var t_com = t.GetComponent<Timer>();if (t_com != null){t_com.Recover(saveLevelInfo.Time);}//saveLevelInfo.Time = t_com.GetSaveInfo();}});}public bool HasSaveVersion(){var version_root = Path.Combine(root, SaveID.ToString());if (Directory.Exists(version_root)){return true;}else{return false;}}internal void NewGame(){var version_root = Path.Combine(root, SaveID.ToString());//bakevar bake_root = Path.Combine(root, bake);//Debug.LogError(bake_root);if (Directory.Exists(bake_root) == false){Directory.CreateDirectory(bake_root);}else{var sub = Directory.GetFiles(bake_root);foreach (var file in sub){File.Delete(file);}}if (Directory.Exists(version_root)){//FileSystem.CopyDirectory(version_root, bake_root, true);var sub = Directory.GetFiles(version_root);foreach (var file in sub){//Debug.LogError(file);//using (var read=new StreamReader(Path.Combine(version_root, file),encoding:System.Text.Encoding.UTF8))//{// var s = read.ReadToEnd();// using (var writer = new StreamWriter(Path.Combine(bake_root, file),false, System.Text.Encoding.UTF8)) {// writer.Write(s);// }//}File.Copy(file, Path.Combine(bake_root, Path.GetFileName(file)), true);}Directory.Delete(version_root, true);}}}
Init方法
该方法的作用是 初始化游戏存档的根目录路径,将 root
字段设置为一个平台无关的、持久化存储路径下的 "Save"
子文件夹。
public void Init(){root = Path.Combine(Application.persistentDataPath, "Save");}
1.
Application.persistentDataPath
是什么?这是 Unity 提供的一个只读属性,返回一个操作系统平台相关的路径,用于存储需要在游戏关闭后仍然保留的数据(如存档、配置、玩家进度等)。
不同平台的典型路径示例:
- Windows:
C:\Users\<用户名>\AppData\LocalLow\<公司名>\<产品名>
这个路径中,
Application.persistentDataPath
的值就是:C:\Users\xiao\AppData\LocalLow\DefaultCompany\My project (1)
2.
Path.Combine(..., "Save")
的作用
- 将
persistentDataPath
与子文件夹名"Save"
安全地拼接成完整路径。- 使用
Path.Combine
而不是字符串拼接(如+ "/" +
),是为了自动适配不同操作系统的路径分隔符
3. 为什么需要这个
root
?后续的存档操作(如
Save()
和Load()
)都会基于这个root
路径创建或读取文件,例如:var version_root = Path.Combine(root, "1000"); // → .../Save/1000/ var v_main = Path.Combine(version_root, "main.txt");
所以必须先通过
Init()
确定root
的值,否则root
为null
Save方法
这个
Save(long versionId, string scene)
方法是 游戏存档的核心逻辑,它的作用是 将当前游戏进度(包括全局状态和当前关卡状态)序列化并保存到磁盘文件中。
存档分为两部分:
- 全局数据(
main.txt
):玩家基本信息、背包、当前场景名等。- 关卡数据(
{scene}.txt
):当前场景中的 NPC、宝箱、计时器等动态对象状态
public void Save(long versionId, string scene){long version = 0;//找到已存档的目录 然后覆盖原有的文件if (versionId > 0){version = versionId;}else{//新建一个档案version = SaveID;// TimeHelper.Now();}var version_root = Path.Combine(root, version.ToString());if (Directory.Exists(version_root) == false){Directory.CreateDirectory(version_root);}//全局文件var v_main = Path.Combine(version_root, "main.txt");//关卡文件var v_scene = Path.Combine(version_root, $"{scene}.txt");//主角数据SaveInfoMain saveInfoMain = new SaveInfoMain();saveInfoMain.Scene = scene;saveInfoMain.Player = SavePlayer(UnitManager.Instance.player);//背包数据SetSaveInfo_BagGrild(saveInfoMain.Grilds);//写入全局数据进行保存var m_data = ProtoHelper.ToBytes(saveInfoMain);File.WriteAllBytes(v_main, m_data);//获取关卡的数据 进行保存SaveLevelInfo saveLevelInfo = new SaveLevelInfo();var npc_root = GameObject.Find("NPC");//场景NPCif (npc_root != null){var fsm = npc_root.transform.GetComponentsInChildren<FSM>(true);if (fsm != null && fsm.Length > 0){foreach (var item in fsm){saveLevelInfo.Ai.Add(item.gameObject.name, SavePlayer(item));}}}//宝箱var chest_root = GameObject.Find(Chest_Root);if (chest_root != null){ChestInfo[] chestInfos = chest_root.GetComponentsInChildren<ChestInfo>(true);if (chestInfos != null && chestInfos.Length > 0){foreach (var item in chestInfos){saveLevelInfo.Chest.Add(item.gameObject.name, new SaveChestInfo(){State = item.state});}}}//关卡剩余的挑战时间var t = GameObject.Find("Timer/T001");if (t != null){var t_com = t.GetComponent<Timer>();saveLevelInfo.Time = t_com.GetSaveInfo();}//计时器 todo..var sceneBytes = ProtoHelper.ToBytes(saveLevelInfo);File.WriteAllBytes(v_scene, sceneBytes);}
1. 确定存档目录
// 如果 versionId 大于 0,就使用 versionId 作为“版本号”;否则使用 SaveID。
long version = versionId > 0 ? versionId : SaveID;// 把版本号转成字符串,并与根目录 root 拼接,得到形如 “root\123” 的完整路径。
var version_root = Path.Combine(root, version.ToString());// 如果该路径对应的文件夹尚不存在,则立即创建它。
if (!Directory.Exists(version_root))Directory.CreateDirectory(version_root);
- 最终存档路径示例:
C:\Users\xiao\AppData\LocalLow\...\Save\1000\
- 这个目录下会存放:
main.txt
→ 全局数据Level1.txt
→ 关卡数据(假设当前场景叫Level1
)
2. 保存全局数据
把当前场景、玩家、背包等关键数据打包成一个对象,序列化成二进制后写进主存档文件
即:ProtoHelper.ToBytes
把对象“压”成二进制;File.WriteAllBytes
把二进制“落”到磁盘。saveInfoMain保存内容:
数据项 说明 Scene
当前场景名,用于下次加载时知道该进哪个关卡 Player
玩家状态(HP、位置、旋转、状态机状态、是否激活) Grilds
背包格子数据(物品ID、数量等)
// 1. 创建一个用于保存“主存档数据”的对象
SaveInfoMain saveInfoMain = new SaveInfoMain();// 2. 把当前场景信息写进去
saveInfoMain.Scene = scene;// 3. 把玩家对象转换成可序列化的 Player 数据并赋值
// SavePlayer 大概率是手写的一个“把运行时 PlayerUnit 转成存档结构”的方法
saveInfoMain.Player = SavePlayer(UnitManager.Instance.player);// 4. 把背包/格子数据也填进去
SetSaveInfo_BagGrild(saveInfoMain.Grilds);// 5. 用 protobuf 把整棵对象树序列化成字节数组
var m_data = ProtoHelper.ToBytes(saveInfoMain);// 6. 一次性写入磁盘,文件路径由变量 v_main 指定(通常是 xxx.sav 或 xxx.dat)
File.WriteAllBytes(v_main, m_data);
File.WriteAllBytes(v_main, m_data);
这里的File是 mscorlib.dll(.NET 底层程序集)里提供的工具类,专门用来一次性完成“文件级”操作:读、写、复制、删除
这里就是将m_data(
saveInfoMain
对象经过 Protocol Buffers 序列化后的整块二进制存档。)——把“主存档对象”序列化后的字节流一次性写进main.txt
文件,完成真正的落盘保存。
3. 保存关卡数据({scene}.txt)
SaveLevelInfo saveLevelInfo = new SaveLevelInfo();
(1)保存 NPC 状态
var npc_root = GameObject.Find("NPC");
// 遍历所有 FSM 组件(代表可存档的 AI/NPC)
foreach (var item in fsm)
{saveLevelInfo.Ai.Add(item.gameObject.name, SavePlayer(item));
}
- 通过名字(如
"1001"
)作为 key,保存每个 NPC 的状态。 - 同样调用
SavePlayer(FSM)
,说明 NPC 和玩家共用一套状态保存逻辑。
(2)保存宝箱状态
var chest_root = GameObject.Find(Chest_Root); // "Chest_Root"
foreach (var item in chestInfos)
{saveLevelInfo.Chest.Add(item.gameObject.name, new SaveChestInfo() { State = item.state });
}
- 保存每个宝箱是否已被打开(
state
字段)。
(3)保存计时器
var t = GameObject.Find("Timer/T001");
if (t != null)
{var t_com = t.GetComponent<Timer>();saveLevelInfo.Time = t_com.GetSaveInfo();
}
- 保存挑战剩余时间等计时信息。
最后:
var sceneBytes = ProtoHelper.ToBytes(saveLevelInfo);
File.WriteAllBytes(v_scene, sceneBytes);
- 将关卡数据序列化,写入如
Level1.txt
的文件。
4.保存关卡数据的原理
比如:
- 某个宝箱已经被打开了 → 下次进来不能还是关着的
- 某个敌人已经被打败了 → 下次进来不能复活
- 倒计时还剩 30 秒 → 下次进来不能重新从 60 秒开始
这些不是全局数据(不属于玩家背包或角色属性),而是属于当前关卡(场景)的数据,所以要单独保存
关卡数据GameSaveProto如下:
// 关卡数据:可能包含多个AI、多个宝箱、关卡剩余的时间、多个计时器
message SaveLevelInfo {map<string, SavePlayerInfo> ai = 1; // AImap<string, SaveChestInfo> chest = 2; // 宝箱float time = 3; // 关卡剩余的时间map<int32, SaveTimerInfo> timer = 4; // 计时器的状态/剩余时间
}message SavePlayerInfo {int32 state = 1; // 状态int32 hp = 2; // 当前的血量repeated float pos = 3; // 当前的位置repeated float rot = 4; // 当前的角度bool active =5;//是否激活玩家
}message SaveGrildInfo {int32 id = 1; // 格子IDint32 prop_id = 2; // 道具IDint32 count = 3; // 剩余的数量
}message SaveChestInfo {int32 state = 1; // 状态
}message SaveTimerInfo {int32 state = 1; // 状态int32 time = 2; // 已经开始了多长时间
}
关键点:用 GameObject 的名字作为“唯一标识”
saveLevelInfo.Ai.Add(item.gameObject.name, SavePlayer(item));
saveLevelInfo.Chest.Add(item.gameObject.name, new SaveChestInfo { State = item.state });
- 例如:一个宝箱 GameObject 叫
"Chest_01"
,它的状态是“已打开”(state = true)- 存档时就记下:
"Chest_01" → { State: true }
- 加载时,程序会:
- 找到场景中叫
"Chest_01"
的 GameObject- 把
State: true
应用给它 → 宝箱变成打开状态
以宝箱为例,解读关卡数据存档流程
步骤 1:找到所有宝箱
var chest_root = GameObject.Find("Chest_Root");
ChestInfo[] chestInfos = chest_root.GetComponentsInChildren<ChestInfo>(true);
- 假设场景里有个空物体叫
Chest_Root
,下面挂着所有宝箱。 GetComponentsInChildren<ChestInfo>(true)
会找到所有(包括未激活的)宝箱组件。
Chest_Root及其内容如图所示
步骤 2:遍历每个宝箱,提取状态
foreach (var item in chestInfos)
{saveLevelInfo.Chest.Add(item.gameObject.name, // 键:宝箱名字,如 "Chest_01"new SaveChestInfo() {State = item.state // 值:当前状态(true=已开)});
}
步骤 3:序列化并写入文件
var sceneBytes = ProtoHelper.ToBytes(saveLevelInfo);
File.WriteAllBytes(".../Save/1000/Level1.txt", sceneBytes);
- 整个
SaveLevelInfo
对象被转成二进制,存进Level1.txt
。
SavePlayer方法
将一个由
FSM
(有限状态机)控制的游戏对象(如玩家或 NPC)的当前运行状态,转换为可序列化的数据结构SavePlayerInfo
,用于存档
public SavePlayerInfo SavePlayer(FSM fsm)
{// 创建一个空的存档数据对象(用于存储玩家FSM数据)SavePlayerInfo savePlayerInfo = new SavePlayerInfo();// 只有 FSM 存在且已经初始化过(yet=true)才抽数据if (fsm != null && fsm.yet){// 1. 当前状态 IDsavePlayerInfo.State = fsm.currentState.id;// 2. 当前血量(向下取整)savePlayerInfo.Hp = (int)fsm.att_crn.hp;// 3. 世界坐标——依次写入 x y zsavePlayerInfo.Pos.Add(fsm._transform.position.x);savePlayerInfo.Pos.Add(fsm._transform.position.y);savePlayerInfo.Pos.Add(fsm._transform.position.z);// 4. 欧拉角旋转——依次写入 x y zsavePlayerInfo.Rot.Add(fsm._transform.eulerAngles.x);savePlayerInfo.Rot.Add(fsm._transform.eulerAngles.y);savePlayerInfo.Rot.Add(fsm._transform.eulerAngles.z);// 5. GameObject 的激活状态(true/false)savePlayerInfo.Active = fsm._gameObject.activeSelf;}// 即使没数据也会把空结构返回,调用方不用判 nullreturn savePlayerInfo;
}
SetSaveInfo_BagGrild方法
这段代码的作用是:将当前背包中的所有物品数据,转换为可序列化的存档格式(
SaveGrildInfo
),并填充到传入的字典dct
中,用于后续整体存档
- 输入:一个空的或待填充的
MapField<int, SaveGrildInfo>
(这是 protobuf 生成的可序列化字典类型)。- 输出:该字典dct被填入所有背包格子的数据。
- 用途:作为
SaveInfoMain.Grilds
的内容,最终写入main.txt
public void SetSaveInfo_BagGrild(MapField<int, SaveGrildInfo> dct)
{dct.Clear(); // 清空旧存档,防止脏数据残留// 遍历运行时背包 BagData.Instance.dct// 假设其结构为 Dictionary<int, BagGrid>,Key 是格子索引,Value 是格子数据foreach (var item in BagData.Instance.dct){SaveGrildInfo info = new SaveGrildInfo(); // 新建一个可序列化的格子信息info.Id = item.Key; // 格子序号info.PropId = item.Value.id; // 道具配置 idinfo.Count = item.Value.count; // 堆叠数量// 写入 protobuf 字典;若键已存在会覆盖,不存在则新增dct[item.Key] = info;}
}
Load方法
从本地磁盘读取之前保存的游戏进度(包括全局数据和当前关卡数据),加载指定场景,并将所有角色、背包、NPC、宝箱、计时器等对象恢复到存档时的状态。
public void Load()//long versionId{//var version_root = Path.Combine(root, versionId.ToString());var version_root = Path.Combine(root, SaveID.ToString());//全局文件var v_main = Path.Combine(version_root, "main.txt");var main_bytes = File.ReadAllBytes(v_main);SaveInfoMain saveInfoMain = ProtoHelper.ToObject<SaveInfoMain>(main_bytes);//关卡文件var v_scene = Path.Combine(version_root, $"{saveInfoMain.Scene}.txt");var scene_bytes = File.ReadAllBytes(v_scene);SaveLevelInfo saveLevelInfo = ProtoHelper.ToObject<SaveLevelInfo>(scene_bytes);GameSystem.Instance.SceneController.Load(saveInfoMain.Scene, false, () => {//恢复主角UnitManager.Instance.player.Recover(saveInfoMain.Player);//恢复背包BagData.Instance.Recover(saveInfoMain);//恢复场景的NPC var npc_root = GameObject.Find("NPC");//场景NPCif (npc_root != null){var fsm = npc_root.transform.GetComponentsInChildren<FSM>(true);if (fsm != null && fsm.Length > 0){foreach (var item in fsm){if (saveLevelInfo.Ai.ContainsKey(item.gameObject.name)){var data = saveLevelInfo.Ai[item.gameObject.name];item.Recover(data);}}}}//宝箱var chest_root = GameObject.Find(Chest_Root);if (chest_root != null){ChestInfo[] chestInfos = chest_root.GetComponentsInChildren<ChestInfo>(true);if (chestInfos != null && chestInfos.Length > 0){foreach (var item in chestInfos){if (saveLevelInfo.Chest.ContainsKey(item.gameObject.name)){var data = saveLevelInfo.Chest[item.gameObject.name];Debug.Log(item.gameObject.name + " " + data.State);item.Recover(data);}//saveLevelInfo.Chest.Add(item.gameObject.name, new SaveChestInfo()//{// State = item.state//});}}}//剩余的关卡时间//关卡剩余的挑战时间var t = GameObject.Find("Timer/T001");if (t != null){var t_com = t.GetComponent<Timer>();if (t_com != null){t_com.Recover(saveLevelInfo.Time);}//saveLevelInfo.Time = t_com.GetSaveInfo();}});
1.确定存档路径并加载全局数据
// 1. 拼接存档目录路径:root + 存档版本号(1000)
var version_root = Path.Combine(root, SaveID.ToString()); // 例如 ".../Save/1000"// 2. 拼接主存档文件完整路径:目录 + main.txt
var v_main = Path.Combine(version_root, "main.txt"); // 例如 ".../Save/1000/main.txt"// 3. 一次性把磁盘上的 main.txt 全部读进内存,返回 byte[]
var main_bytes = File.ReadAllBytes(v_main);// 4. 用 protobuf 反序列化,把二进制数据重新变成 SaveInfoMain 对象
SaveInfoMain saveInfoMain = ProtoHelper.ToObject<SaveInfoMain>(main_bytes);
- 和
Save()
方法对应,读取的是Save/1000/
目录(单存档槽设计)。- 反序列化出
SaveInfoMain
,包含:
Player
:玩家存档状态(位置、HP、状态机等)Grilds
:背包格子数据Scene
:上次退出时所在的场景名(如"Level1"
)- 根据
saveInfoMain.Scene
动态拼出关卡存档文件名(如Level1.txt
)。- 反序列化出该关卡的动态对象状态:
Ai
:NPC 状态字典(key = GameObject 名字)Chest
:宝箱状态字典Time
:计时器数据
2.异步加载场景 + 恢复状态
GameSystem.Instance.SceneController.Load(saveInfoMain.Scene, false, () => {…………// 所有恢复逻辑都在这里执行(如NPC、玩家、宝箱数据)
});
- 使用场景管理器异步加载目标场景(避免卡顿)。
- 回调函数
() => { ... }
在场景完全加载后执行,此时所有 GameObject 已存在,可以安全操作
2.1恢复状态——恢复玩家状态
UnitManager.Instance.player.Recover(saveInfoMain.Player);
- 调用玩家 FSM 的
Recover(SavePlayerInfo)
方法,还原:
- 位置、旋转
- HP
- 状态机当前状态(如 Idle/Run)
- 激活状态(activeSelf)
2.2恢复状态——恢复背包物品
BagData.Instance.Recover(saveInfoMain);
- 背包系统根据
saveInfoMain.Grilds
重建所有格子的物品和数量
2.3恢复状态——恢复场景中的 NPC
// 1. 在场景中查找名为 "NPC" 的根节点(若无则返回 null)
var npc_root = GameObject.Find("NPC");// 2. 安全获取根节点下所有子对象(包含隐藏)的 FSM 组件数组
var fsm = npc_root?.GetComponentsInChildren<FSM>(true) ?? new FSM[0];// 3. 遍历每个 FSM(即每个 NPC)
foreach (var item in fsm)
{// 4. 如果存档字典里存过该 NPC 的数据,就调用 Recover 恢复其状态if (saveLevelInfo.Ai.ContainsKey(item.gameObject.name)){item.Recover(saveLevelInfo.Ai[item.gameObject.name]);}
}
- 关键机制:通过 GameObject 名字匹配存档数据
- 如果存档中有
"Goblin_01"
的状态,就找到场景中叫"Goblin_01"
的 FSM 并恢复它。GetComponentsInChildren<FSM>(true)
包含未激活对象,确保死亡的敌人也能恢复为“死亡状态”。
2.4恢复状态——恢复宝箱状态
if (saveLevelInfo.Chest.ContainsKey(item.gameObject.name))
{item.Recover(data); // 比如设置 state = true(已打开)
}
- 同样通过名字匹配,将宝箱设为“已打开”或“关闭”状态。
HasSaveVersion方法
检查本地是否存在指定 ID(
SaveID
)的游戏存档。
public bool HasSaveVersion()
{// 拼接存档目录路径:root + 版本号(1000)var version_root = Path.Combine(root, SaveID.ToString());// 用 System.IO.Directory 判断目录是否存在if (Directory.Exists(version_root)){return true; // 存在}else{return false; // 不存在}
}
NewGame方法
该方法是控制“点击新游戏时”的存档过程
/// 1. 若不存在 bake 目录则创建;存在则清空其内所有文件;
/// 2. 若旧存档目录存在,则把旧存档文件全部拷进 bake 做备份,随后整目录删除。
/// </summary>internal void NewGame()
{/* 旧存档目录:root\1000 */var version_root = Path.Combine(root, SaveID.ToString());/* 备份目录:root\bake */var bake_root = Path.Combine(root, bake);// 备份目录不存在就新建if (Directory.Exists(bake_root) == false){Directory.CreateDirectory(bake_root);}else{// 已存在则清空里面的文件,避免旧数据残留var sub = Directory.GetFiles(bake_root);foreach (var file in sub){File.Delete(file);}}// 如果旧存档目录存在,则先做备份再删除if (Directory.Exists(version_root)){// 把旧存档目录下的所有文件拷到 bake 目录(同名覆盖)var sub = Directory.GetFiles(version_root);foreach (var file in sub){File.Copy(file, Path.Combine(bake_root, Path.GetFileName(file)), true);}// 整个旧存档目录连文件带子目录一并删除Directory.Delete(version_root, true);}
}
1.定义路径
var version_root = Path.Combine(root, SaveID.ToString()); // 如 Save/1000
var bake_root = Path.Combine(root, bake); // 如 Save/bak
version_root
:当前使用的存档目录(比如1000
号存档)。bake_root
:备份目录
2.确保备份目录存在,并清空它
if (Directory.Exists(bake_root) == false)
{Directory.CreateDirectory(bake_root);
}
else
{var sub = Directory.GetFiles(bake_root);foreach (var file in sub){File.Delete(file); // 清空旧的备份内容}
}
- 如果
bak
目录不存在,就创建它。 - 如果已存在,先删除里面所有文件,确保备份是“干净的”,避免混入上次的备份。
3.如果有旧存档,先备份再删除
if (Directory.Exists(version_root))
{// 遍历 version_root 中的所有文件var sub = Directory.GetFiles(version_root);foreach (var file in sub){// 将每个文件复制到 bak 目录(覆盖同名文件)File.Copy(file, Path.Combine(bake_root, Path.GetFileName(file)), true);}// 复制完成后,彻底删除原存档目录Directory.Delete(version_root, true);
}
- 备份:把
Save/1000/main.txt
、Save/1000/Level1.txt
等全部复制到Save/bak/
。 - 删除:用
Directory.Delete(..., true)
递归删除整个1000
目录(包括子目录和文件)。
4.NewGame中的数据流向
整个流程是:
-
先清空
bake
文件夹 -
然后,把当前存档(
version_root
,比如Save/1000
)里的所有文件,复制进这个“刚刚清空”的bake
文件夹。- 源:
Save/1000/main.txt
→ 目标:Save/bak/main.txt
- 源:
Save/1000/Level1.txt
→ 目标:Save/bak/Level1.txt
- 源:
-
复制完成后,把原来的
Save/1000
整个删掉
所以,数据流向是:
Save/1000/ (旧存档)↓ 复制(备份)
Save/bak/ (临时备份区,内容 = 旧存档的完整副本)↓
Save/1000/ 被彻底删除
ProtoHelper方法
public class ProtoHelper
{public static byte[] ToBytes(object message){return ((Google.Protobuf.IMessage)message).ToByteArray();}public static T ToObject<T>(byte[] bytes) where T : Google.Protobuf.IMessage,new(){var message = new T();// Activator.CreateInstance<T>();message.MergeFrom(bytes);return message;}public static T Clone<T>(object message) where T : Google.Protobuf.IMessage, new(){return ToObject<T>(ToBytes(message));}}
ToBytes方法
万能 protobuf 序列化辅助函数——给任何 IMessage 对象,就吐出对应的二进制存档。
public static byte[] ToBytes(object message){return ((Google.Protobuf.IMessage)message).ToByteArray();}
return ((Google.Protobuf.IMessage)message).ToByteArray();
先强制类型转换:把
object
→Google.Protobuf.IMessage
再调用接口方法
.ToByteArray()
,得到byte[]
立即
return
出去
用法:
var sceneBytes = ProtoHelper.ToBytes(saveLevelInfo);
就是把
saveLevelInfo
这个 protobuf 对象 按 Protocol Buffers 二进制格式 序列化,最终得到一块byte[]
(二进制数据),后面就可以写磁盘或发网络。
Object<T>(byte[] bytes)方法
- 功能:将字节数组反序列化为指定类型的 protobuf 对象。
- 约束:
T
必须实现IMessage
接口(即是一个 protobuf 消息类型)。T
必须有无参构造函数(new()
约束),以便能通过new T()
创建实例。- 说明:
- 创建一个新实例后,调用
MergeFrom(bytes)
将字节数据解析并填充到该实例中。- 这是 protobuf 反序列化的标准做法。
public static T ToObject<T>(byte[] bytes) where T : Google.Protobuf.IMessage, new(){// 创建空对象var message = new T();// 通过 protobuf 自带的 MergeFrom 把二进制流合并到对象中message.MergeFrom(bytes);return message;}
把二进制数据
bytes
还原成 protobuf 对象。
message.MergeFrom(bytes);
按 Protocol Buffers 格式解析字节数组
把字段值填进
message
的对应属性/成员若对象里原有数据会被覆盖或合并(protobuf 的合并规则)
return message;
返回已经填充好的对象,供外部直接使用
合起来就是“反序列化”步骤:字节 → 对象。
用法:
SaveInfoMain saveInfoMain = ProtoHelper.ToObject<SaveInfoMain>(main_bytes);
main_bytes
是前面File.ReadAllBytes(v_main)
得到的整块二进制数据
ProtoHelper.ToObject<SaveInfoMain>
按 protobuf 格式解析,把字段值填进新创建的SaveInfoMain
实例结果:
saveInfoMain
里就装着之前存进去的场景名、玩家数据、背包格子等全局信息,后面代码就能直接用了。
Clone<T>(object message)方法
public static T Clone<T>(object message) where T : Google.Protobuf.IMessage, new(){return ToObject<T>(ToBytes(message));}
这是一行代码实现的 “protobuf 深拷贝” 工具:
先把任意对象序列化成二进制(
ToBytes(message)
)立刻把同一段二进制反序列化回一个新的
T
实例(ToObject<T>(...)
)
SceneController(更新后的Load方法与LoadSceneAsync
)
第三章——SceneController中加载场景相关方法
在第三章的基础上,修改了导入的参数(新增了切换之后的场景名,是否需要重置,以及场景切换后将触发的事件),另外还用了state参数用于判断当前场景是否位于加载状态
public void Load(string next, bool reset = false, Action act = null){LoadingViewController.Instance.Open();LoginViewController.Instance.Close();state = 1;StartCoroutine(LoadSceneAsync(next, false, act));}
LoadSceneAsync方法
这里自己加了一段:根据切换后场景名称不为Init,才决定是否要加载角色——因为退出并存档时会进入Init场景,照样会调用创建角色的方法,但此场景中没有GatePoint所以会导致报错
这里需要注意,创建角色需要在当前帧渲染之前:否则下一场景的元素还没等加载结束就会出现在加载场景中
这个
WaitForEndOfFrame
确保:
- 所有在
op.allowSceneActivation = true; yield return op;
之后的逻辑(包括角色创建、摄像机设置等)已经在当前帧渲染前完成。- 然后才关闭加载界面,这样 下一帧直接就是完整游戏画面
IEnumerator LoadSceneAsync(string next, bool reset, Action act){var op = SceneManager.LoadSceneAsync(next);op.allowSceneActivation = false;while (op.progress < 0.9f){yield return new WaitForEndOfFrame();LoadingViewController.Instance.UpdateLoadProgress(op.progress);}float progress = op.progress;while (progress <= 1){progress += GameTime.deltaTime;LoadingViewController.Instance.UpdateLoadProgress(progress);yield return new WaitForEndOfFrame();}op.allowSceneActivation = true;yield return op;// ✅ 统一判断:是否为游戏场景(非 Init)if (next != "Init"){var player = UnitManager.Instance.CreatePlayer();GameSystem.Instance.CameraController.SetTarget(player.transform);MainViewController.Instance.Open();var gate_point = GameObject.Find("GatePoint");if (gate_point != null){var t = gate_point.transform.Find("0");if (t != null){player.GetComponent<FSM>().SetPosition(t);}}} yield return new WaitForEndOfFrame();LoadingViewController.Instance.Close();GameEvent.OnSceneLoadComplete?.Invoke();if (reset){ResetToLast();}state = 0;act?.Invoke();}