第七章——流程逻辑
缓存文件
Vscode设置
下载扩展如下
Unity编辑器中的设置
将ProtoBuff文件夹拖拽进入Unity中后
在ProjectSetting中如下设置
添加模块资源——L2Cpp(并在ProjectSetting中设定好)
文件夹中的设置
GenAllC中设置如下:
一旦运行该GenAllC就会使Proto文件夹中更新GameSaveProto脚本
@echo offset "PROTOC_EXE=%cd%\tool\protoc.exe"
set "WORK_DIR=%cd%\ProtoFile":: 输出路径:注意路径中包含空格和括号,必须用引号保护
set "CS_OUT_PATH=%cd%\..\..\My project (1) - L4\Assets\Script\Proto":: 确保输出目录存在(关键!)
if not exist "%CS_OUT_PATH%" mkdir "%CS_OUT_PATH%":: 遍历所有 .proto 文件并生成 C# 代码
for /f "delims=" %%i in ('dir /b "%WORK_DIR%\*.proto"') do (echo gen protoFile/%%i..."%PROTOC_EXE%" --proto_path="%WORK_DIR%" --csharp_out="%CS_OUT_PATH%" "%WORK_DIR%\%%i"
)echo finish...
pause
批处理(
.bat
)脚本的作用是:使用 Google Protocol Buffers(protobuf)的编译器protoc.exe
,将指定目录下的.proto
文件批量编译成 C# 代码(.cs
文件),并输出到 Unity 项目的脚本目录中。
该段代码理解:
@echo off
- 作用:关闭命令回显(即运行时不显示每条命令本身,只显示输出内容),让界面更干净。
set "PROTOC_EXE=%cd%\tool\protoc.exe"
- 作用:定义一个变量
PROTOC_EXE
,指向当前目录下tool
文件夹中的protoc.exe
。%cd%
表示当前批处理文件所在的完整路径。- 例如:如果脚本在
E:\unity practice\Tools\protocol-buffers\gen_proto.bat
,那么%cd%
就是E:\unity practice\Tools\protocol-buffers
set "WORK_DIR=%cd%\ProtoFile"
- 作用:定义
.proto
源文件所在的目录。- 即所有要编译的
.proto
文件都放在当前目录下的ProtoFile
文件夹中。
: 这里的路径要改变一下与Proto文件夹相对应 set "CS_OUT_PATH=%cd%\..\..\My project (1) - L4\Assets\Script\Proto"
- 作用:设置 C# 生成文件的输出目录。
%cd%\..\..\
表示向上回退两级目录。
- 例如:当前在
E:\unity practice\Tools\protocol-buffers
- 那么
%cd%\..\..
就是E:\unity practice
- 最终路径:
E:\unity practice\My project (1) - L4\Assets\Script\Proto
- 这个路径通常是你的 Unity 项目中的脚本目录,方便生成的协议类被 Unity 使用。
if not exist "%CS_OUT_PATH%" mkdir "%CS_OUT_PATH%"
- 作用:本意是“如果输出目录不存在,就创建它”。
- 但被注释掉了(前面有
::
),所以当前不会创建目录
- 问题:如果该目录不存在,
protoc
会报错(正如你之前遇到的"No such file or directory"
)for /f "delims=" %%i in ('dir /b "ProtoFile\*.proto"') do (
- 作用:遍历
ProtoFile
文件夹中所有.proto
文件。dir /b "ProtoFile\*.proto"
:以简洁格式(只有文件名)列出所有.proto
文件。for /f "delims=" %%i
:逐个读取每个文件名(delims=
表示不按空格/制表符分割,保留完整文件名,防止文件名含空格出错)。%%i
是循环变量,代表当前.proto
文件名(如GameSaveProto.proto
)。
echo gen protoFile/%%i...
- 作用:在控制台打印当前正在处理的文件,用于提示和调试
"%PROTOC_EXE%" --proto_path="%WORK_DIR%" --csharp_out="%CS_OUT_PATH%" "%WORK_DIR%\%%i"
- 作用:调用
protoc.exe
编译单个.proto
文件。
"%PROTOC_EXE%"
:带引号的可执行文件路径(防止空格问题)。
--proto_path=...
:指定.proto
文件的搜索根目录(import 时会用到)。--csharp_out=...
:指定生成 C# 代码的输出目录。"%WORK_DIR%\%%i"
:当前要编译的具体.proto
文件的完整路径。
沙盒目录工具
打开沙盒目录用于设置存档目录。
创建了
Save
子目录,用来存放游戏存档文件。
存档的原理机制
Proto文件详解
这段代码是一个 Protocol Buffers(简称 Protobuf) 的
.proto
文件,文件名为GameSaveProto.proto
,它定义了一套用于序列化和反序列化数据的结构。这类文件常用于游戏开发、网络通信、跨平台数据存储等场景中,以实现高效、轻量级的数据交换
syntax = "proto3";option csharp_namespace = "Google.Protobuf.Gs";message SaveInfoMain {string scene = 1; // 所处的场景SavePlayerInfo player = 2; // 主角的数据map<int32, SaveGrildInfo> grilds = 3; // 背包信息
}// 关卡数据:可能包含多个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; // 已经开始了多长时间
}
message
是什么类型
message
是 Protobuf 中自定义数据结构的基本单位。- 它类似于 C# 中的
class
或struct
,Java 中的class
,Python 中的dataclass
。
map<key, value>
是什么类型?
map<K, V>
是 Protobuf 提供的键值对映射类型,等价于:
- C# 中的
Dictionary<K, V>
核心消息结构解析
1.
SaveInfoMain
—— 整体存档主结构message SaveInfoMain {string scene = 1; // 所处的场景SavePlayerInfo player = 2; // 主角的数据map<int32, SaveGrildInfo> grilds = 3; // 背包信息 }
- 表示一个完整的“游戏存档”数据。
- 包含三个关键部分:
- 当前所在场景名称(如
"MainStage"
)- 玩家角色的状态(血量、位置等)
- 背包里的物品列表(用
map<int32, ...>
实现键值对,key 是格子 ID)
2.
SaveLevelInfo
—— 关卡内部状态message SaveLevelInfo {map<string, SavePlayerInfo> ai = 1; // AImap<string, SaveChestInfo> chest = 2; // 宝箱float time = 3; // 关卡剩余的时间map<int32, SaveTimerInfo> timer = 4; // 计时器的状态/剩余时间 }
- 描述某个关卡内的动态对象状态。
- 示例用途:
- 记录每个 AI 角色的位置、血量
- 宝箱是否已被打开
- 倒计时还剩多久
- 这些信息通常不会保存到全局存档,而是作为
SaveInfoMain
的一部分或单独保存。
3.
SavePlayerInfo
—— 玩家/角色基础信息message SavePlayerInfo {int32 state = 1; // 状态int32 hp = 2; // 当前的血量repeated float pos = 3; // 当前的位置 (x, y, z)repeated float rot = 4; // 当前的角度 (pitch, yaw, roll 或 Euler angles)bool active = 5; // 是否激活玩家 }
- 用于表示任意一个角色(包括主角和 AI)的状态。
pos
和rot
使用repeated float
来存储三维向量,例如:pos: [10.0, 5.0, 0.0] rot: [0.0, 90.0, 0.0]
active
控制角色是否处于活动状态(如死亡后不活跃)
4.
SaveGrildInfo
—— 背包格子信息message SaveGrildInfo {int32 id = 1; // 格子IDint32 prop_id = 2; // 道具IDint32 count = 3; // 剩余的数量 }
- 表示背包中的单个格子。
- 例如:格子 ID=100,放的是道具 ID=201(剑),数量=3。
5.
SaveChestInfo
—— 宝箱状态message SaveChestInfo {int32 state = 1; // 状态(0=未开,1=已开) }
- 很简单,只记录宝箱是否被开启过。
- 可扩展为更多字段(如奖励内容、是否刷新等)
6.
SaveTimerInfo
—— 计时器状态message SaveTimerInfo {int32 state = 1; // 状态(运行中?暂停?结束?)int32 time = 2; // 已经开始了多长时间(秒) }
- 用于保存游戏中各种倒计时器的状态。
- 例如:限时任务、技能冷却、机关触发时间等。
Proto文件修改流程
1、修改Proto文件
主要先想好哪些数据内容需要被存档。
message SavePlayerInfo {int32 state = 1; // 状态int32 hp = 2; // 当前的血量repeated float pos = 3; // 当前的位置repeated float rot = 4; // 当前的角度bool active =5;//是否激活玩家int32 atkTargetGlobalId = 6;//全局ID
}
这里的数字代表的序列——而不是默认常数
2、进行更新
3、在对应类型中进行赋值
public SavePlayerInfo(SavePlayerInfo other) : this() {state_ = other.state_;hp_ = other.hp_;pos_ = other.pos_.Clone();rot_ = other.rot_.Clone();active_ = other.active_;atkTargetGlobalId_= other.atkTargetGlobalId_;//符号和顺序要一一对应_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
当加入新参数atkTargetGlobalId_时,会自动生成对应获取方法如下:
public int AtkTargetGlobalId {get { return atkTargetGlobalId_; }set {atkTargetGlobalId_ = value;}
}
4、调用该参数
if (Player.AtkTargetGlobalId != -1) // 注意:A 大写,无下划线!{FSM target = UnitManager.Instance.GetUnitByGlobalId(Player.AtkTargetGlobalId);if (target != null){currentTargetInstanceId = target.instance_id; // 更新为当前有效的 instance_idDebug.Log($"恢复攻击目标: {target.name}");}else{currentTargetInstanceId = -1; // 目标不存在,清空Debug.Log("存档中的攻击目标未找到,已清空");}}else{currentTargetInstanceId = -1;}ToNext(Player.State);}
这里不是直接调用的该参数,而是获取该参数的方法!
代码流程——存档和读档机制的运行
模块 | 职责 |
---|---|
C# 脚本 (GameSave.cs ) | 控制存档/读档流程,收集游戏对象状态,调用 Protobuf 工具进行序列化/反序列化,读写文件 |
Protobuf .proto 文件 | 定义存档数据结构,生成 C# 类(如 SaveInfoMain , SavePlayerInfo 等),用于数据传输和持久化 |
存档和读档要点前瞻
反序列化 → 直接使用数据 → 调用 Recover
存档时(序列化):
- 从游戏对象(如玩家、NPC)提取状态 → 填入 Protobuf 对象(如
SavePlayerInfo
)- 将 Protobuf 对象 序列化为字节 → 写入文件
读档时(反序列化):
- 从文件 读取字节
- 反序列化 → 得到内存中的 Protobuf 对象(如
SavePlayerInfo
实例)- 直接使用这个对象的数据 → 调用
player.Recover(savePlayerInfo)
- 序列化:把“活人”(游戏对象)做成“档案”(二进制文件)
- 反序列化:把“档案”还原成“纸质简历”(C# 对象)
- Recover:HR(游戏系统)根据“简历”重新雇佣这个人(恢复游戏状态)
存档(Save)时的联动
1. 构建 Protobuf 数据结构
-
创建
SaveInfoMain
(全局数据):- 场景名(
scene
) - 玩家状态(
SavePlayer(UnitManager.Instance.player)
→SavePlayerInfo
) - 背包格子数据(
SetSaveInfo_BagGrild
→ 填充grilds
map)
- 场景名(
-
创建
SaveLevelInfo
(关卡数据):- 所有 NPC 的状态(通过
FSM
组件 →SavePlayerInfo
) - 所有宝箱状态(
ChestInfo
→SaveChestInfo
) - 关卡计时器状态(
Timer.GetSaveInfo()
→float time
)
- 所有 NPC 的状态(通过
2. 序列化并写入文件
- 使用
ProtoHelper.ToBytes(...)
将 Protobuf 对象序列化为字节数组 - 分别写入两个文件:
main.txt
:存SaveInfoMain
{scene}.txt
:存SaveLevelInfo
读档(Load)时的联动
1. 从文件读取并反序列化
- 读取
main.txt
→ProtoHelper.ToObject<SaveInfoMain>
- 读取
{scene}.txt
→ProtoHelper.ToObject<SaveLevelInfo>
2. 加载场景并恢复状态
- 先加载场景(
GameSystem.Instance.SceneController.Load(...)
) - 在场景加载完成后回调中恢复状态:
恢复主角
UnitManager.Instance.player.Recover(saveInfoMain.Player);
要求
FSM
或Player
类有Recover(SavePlayerInfo)
方法
恢复背包
BagData.Instance.Recover(saveInfoMain);
从
saveInfoMain.Grilds
map 恢复背包数据
恢复 NPC
- 遍历所有
FSM
组件 - 若其
name
在saveLevelInfo.Ai
中,则调用item.Recover(data)
恢复宝箱
- 遍历
ChestInfo
组件 - 若其
name
在saveLevelInfo.Chest
中,则调用item.Recover(data)
恢复计时器
t_com.Recover(saveLevelInfo.Time);
✅ 联动点:Protobuf 反序列化出结构化数据 → C# 脚本遍历游戏对象 → 调用各组件的
Recover
方法恢复状态
读档机制——游戏场景实例的摧毁(数据机制的详解)
「读档前必须切换场景」这个操作(LoadSceneAsync Single 模式)把当前场景全部卸载,所有场景内的 GameObject 自然都被销毁了。
时点 | 场景实例(NPC、宝箱、玩家等) | 备注 |
---|---|---|
游戏运行中 | 存在于内存,可以被修改、销毁、创建 | 这些都是「运行时副本」 |
执行存档 | 只把需要的数据(位置、血量、状态、是否已开宝箱等)写成文件;不存 GameObject 本身 | 文件里只有数据,没有实例 |
读档前切换场景 | LoadSceneAsync(next, Single) → 旧场景完全卸载 → 所有 GameObject 被销毁 | Unity 自带行为,与存档无关 |
新场景加载完成 | 磁盘上的 .unity 文件重新生成初始版本的实例(你在 Editor 里摆的那个状态) | 如果场景文件里没摆,就不会出现 |
读档回调执行 | 你用代码 Recover() 把存档数据重新赋给「新实例」 | 如果场景里没有对应物体,或脚本没动态创建,就会「看起来消失了」 |
1. 场景里有一个宝箱
GameObject 名字:
Chest_01
位置:
(10, 0, 5)
状态:已开启
掉落物品:金币 × 50
2. 点击存档时
不会把 Chest_01
这个 GameObject 整体写进文件,而是只写一行数据:
{"Chest_01": {"position": { "x": 10, "y": 0, "z": 5 },"isOpened": true,"loot": "金币_50"}
}
文件里没有模型、没有贴图、没有碰撞体,只有**“它叫什么、在哪、是否被打开”**这些数字/字符串。
这就是「只存数据,不存实例」。
3. 读档时
Unity 先把场景重新加载(磁盘上的
.unity
文件重新生成一个新的Chest_01
)。你的代码
Find("Chest_01")
找到这个新物体,再把存档里的isOpened = true
赋给它 → 它变成「已开启」状态。
代码流程——在登陆页面打开游戏并加载
1、获取按钮组件并绑定对应方法
1、如果点击“新游戏”,则(根据Bake文件夹是否存在)备份存档且进入加载页面(并且在加载页面中加载下一场景的对应资源)
备份存档相关代码如下
GameSave——NewGamehttps://blog.csdn.net/2303_80204192/article/details/153051173#t18
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;public class LoginView : View
{Button ContinueBtn;public override void Awake(){base.Awake();var new_game = transform.Find("Btn/NewGame").GetComponent<Button>();new_game.onClick.AddListener(NewGame);ContinueBtn = GetComponent<Button>("Btn/Continue");ContinueBtn.onClick.AddListener(ContinueBtnOnClick);if (GameSave.Instance.HasSaveVersion()){}else{}}private void ContinueBtnOnClick(){GameSave.Instance.Load();}private void NewGame(){//打开Loading //切换场景//更新场景的加载进度GameSystem.Instance.SceneController.Load("Game", false, BagData.Instance.Init);GameSave.Instance.NewGame();}
此外,还有代码根据是否有存档记录来判断登录页面是否有“继续游戏”的选项
public override void OnEnable(){base.OnEnable();ContinueBtn.gameObject.SetActive(GameSave.Instance.HasSaveVersion());}
2、NavView导航面板退出游戏
这个
QuitGame()
方法是一个 “退出游戏”操作的完整流程控制函数。其核心功能是在用户确认退出时,自动保存当前游戏进度,并返回到初始登录界面(如主菜单或登录页),同时关闭当前 UI。
在此处点击确认就开始存档:
GameSave.Instance.Save(0, GameSystem.Instance.SceneController.GetActiveScene());
private void QuitGame()
{// 1️⃣ 弹出确认提示框TipsViewController.Instance.Show("您是否退出游戏?将进行数据存档.", () => {// ✅ 用户点击“确认”时执行以下逻辑:// 2️⃣ 自动存档:保存当前场景和游戏状态GameSave.Instance.Save(0, GameSystem.Instance.SceneController.GetActiveScene());// 3️⃣ 关闭当前界面(比如设置菜单、暂停菜单等)Close(false);// 4️⃣ 关闭所有已打开的 UI 界面(防止残留)ViewManager.Instance.CloseAll?.Invoke(false);// 5️⃣ 加载初始场景(如主菜单或登录场景)GameSystem.Instance.SceneController.Load("Init", false, () => {// 6️⃣ 场景加载完成后,打开登录界面LoginViewController.Instance.Open();});}, () => { // ❌ 用户点击“取消”时执行:Close(false); // 仅关闭当前提示或菜单,不退出});
}
2.1这里有个比较新奇的函数方法
在这里确立了点击导航提示板中的"您是否退出游戏?将进行数据存档."——“点击确认”的事件和“点击取消”的事件。
public class TipsViewController : ViewController<TipsViewController, TipsView>
{public void Show(string tips, Action enter = null, Action cancel = null){Open();SetTipsToTop();view.Show(tips, enter, cancel);}
}将提示板中的点击——确认和取消设为事件方法
public class TipsView : View
{public void Show(string tips, Action enter, Action cancel){SetText(Info, tips);this.EnterAction = enter;this.CancelAction = cancel;}
3、存档机制
GameSave——Save方法https://blog.csdn.net/2303_80204192/article/details/153051173#t2
它的作用是 将当前游戏进度(包括全局状态和当前关卡状态)序列化并保存到磁盘文件中。将全局数据(玩家信息)与关卡数据(NPC状态、宝箱状态、计时器状态)进行存档。
4、继续游戏
在QuitGame方法中,再次打开了登录页面LoginView,点击“继续游戏”
private void ContinueBtnOnClick(){GameSave.Instance.Load();}
加载之前的存档内容,并调用Load方法——加载存档并且将存档中的对应数据恢复至游戏内容中
GameSave——Load方法https://blog.csdn.net/2303_80204192/article/details/153051173#t12
在场景加载结束后正式使用存档数据恢复场景实例
异步加载场景 + 恢复状态https://blog.csdn.net/2303_80204192/article/details/153051173#t13
这里需要注意先SceneController.Load(在这里将10001作为SaveInfoMain.scene传递),然后完成LoadSceneAsync异步场景更新卸载旧场景(且加载完成打开10001新场景)
并将新场景10001中的实例,导入存档之前的数据。
存档时的一些Bug
攻击对象重置但是却记录了引用(监听列表的未清除)
MissingReferenceException: The object of type 'Transform' has been destroyed...
这个错误说明:
你保存存档时,可能保存了某个敌人的状态(比如位置、状态、HP),但没有正确恢复它的 GameObject 或 FSM 实例。
读档时:
你可能 重新实例化了敌人(或没实例化);
但 攻击逻辑里还引用着旧的
FSM
实例;这个旧的
FSM
实例的_transform
已经被销毁了。
找到问题如下:
报错点不在“存档”本身,而在反序列化后第一次进入战斗时,this._transform
已经被销毁,但AI 消息系统仍然调用了 OnPlayerAtk
,于是访问 _transform.position
抛出 MissingReferenceException
。
一、为什么会被销毁
-
存档时你把玩家/NPC 的运行时数据(位置、血量、状态等)写进文件。
-
读档时 Unity 先把旧场景完全卸载(
LoadSceneAsync
Single 模式),所有GameObject
被销毁,包括this.gameObject
。 -
场景重新加载后,磁盘上的新物体已经生成,但旧的 FSM 实例(也就是
this
)还残留在某个委托/事件里,没有被清掉。 -
当玩家再次攻击,消息系统遍历旧的监听列表,拿到一个已销毁对象的 FSM,调用
OnPlayerAtk
→ 访问_transform.position
→ 报错
所以要清除旧的监听列表:
private void OnDisable()
{if (AI){if (unitEntity.type == 3){Debug.Log($"unitEntity.info:{unitEntity.info}");MainViewController.Instance.EnableBossHP(false, unitEntity.info);}RemoveListener();}
}public void RemoveListener()
{GameEvent.OnPlayerAtk -= OnPlayerAtk;
}
它不随场景卸载而自动清空,也不感知 GameObject 是否被销毁。
只要旧实例曾经+= 注册
过,它的方法地址就一直留在委托链里;场景切换时 Unity 只销毁了 GameObject 和组件,但没有帮你把委托链里的空壳踢掉,于是:
旧场景卸载 → 物体全灭。
新场景加载 → 新物体注册同名事件。
事件触发 → 委托链先执行旧实例(只剩一个空壳)→ 访问任何成员都报
MissingReferenceException
。因此这里要将切换场景时,物体及物体组件摧毁时绑定移除旧的监听表
对象池中一些可复用的物件被摧毁
存档时,某些技能/暗器 GameObject 仍处于“激活”状态(未被回收到对象池),但它们的 GameObject 或其父对象在读档时已被销毁或重建,而对象池中仍保留着对这些已销毁对象的引用