4.5 任务系统与存档:把玩家的进度装进分层抽屉
核心结论
任务(Quest)与存档(Save)本质是同一件事:把玩家的"状态进度"有组织地持久化。任务系统的结构决定了存档的结构。按 0102 的存档分层原则设计,可以避开"存档神类"的失控。
任务系统的三层
| 层 | 内容 | 例 |
|---|---|---|
| 任务定义 | 数据表:目标、奖励、前置 | DT_Quest |
| 任务状态 | 运行时:进度、完成、领取奖励 | PlayerState 或 存档 |
| 任务表现 | UI/通知 | "任务完成"横幅 |
任务目标的数据化——目标不是"杀死 10 个哥布林",而是结构:
struct FQuestObjective {
GoalType=Kill, TargetTag=Enemy.Goblin, Count=10
CurrentCount=3, bOptional=false
}
所有"如何完成"都是 GoalType 枚举的分支:Kill、Collect、Reach、Talk、Survive。加新目标类型 = 加一个枚举 + 一个事件监听,不是改任务表。
任务事件流
[世界事件:OnEnemyKilled(Tag)]
-> 任务系统:推进所有 Kill 型目标
-> 目标满 -> Quest Complete 广播
-> UI 横幅 + 奖励 GE(或 GA 发奖)
-> 新任务解锁(按前置表)
注意:事件源只有一处——OnEnemyKilled 广播的地方(0205 的死亡链路)。任务系统订阅它,而不是自己再实现一遍击杀监听。单一事件源是防漂移的关键。
存档的分层实现
| 层 | 内容 | 存哪 |
|---|---|---|
| 主存档 | 任务状态、等级、世界进度(种子/波次) | SaveSlot_Main |
| 玩家偏好 | 按键、画质、音量 | SaveSlot_Options(独立) |
| 运行时缓存 | 设置外的临时状态 | 不持久化 |
SaveGame 结构:
URogueSaveGame
+- int32 Version = 4 -- 存档版本(迁移用)
+- FGameData: 总局(种子、游戏时长)
+- FPlayerData: 等级/已解锁 GA Tag/属性
+- FQuestData[]: 任务状态数组
+- 各层各自的结构(世界/掉落种子...)
版本号机制:每次存档第一行读 Version,按版本走迁移函数(MigrateFromV2ToV3)。没有版本号的存档系统,第一次大更新就会删玩家存档。
存档触发的时机
| 时机 | 存 | 读 |
|---|---|---|
| 进入新房间 | 增量存 | - |
| 任务完成 | 存 | - |
| 暂停/退出 | 存 | - |
| 游戏开始 | - | 读主存档 |
| 设置变更 | 存偏好 | - |
纪律:所有写存档走同一个 SaveGame 接口,各系统只"注册数据块",不直接写文件(0102 的存档分层落地)。
常见坑与解法
坑 1:任务进度联机不同步
原因:任务状态存在 PlayerState 但没复制,或每个玩家各自算。
解法:任务分类型:个人任务存在 PlayerState(复制);小队共享任务存在 PlayerState(队长)或 GameState(全队)——设计先想清"这个任务算谁的"。
坑 2:存档损坏后玩家进不去
解法: - 存档带校验和(简单哈希),坏了弹"存档损坏"而不是崩。 - 双存档槽位:主槽 + 备用槽,每次写主槽,崩溃后恢复备用。
坑 3:不退出游戏更新存档结构
原因:老存档字段被删/改名,旧字段读不出来崩。
解法:版本号 + 迁移;删除字段前留一个版本的温和过渡(往 Version 迁移函数里写默认值)。
坑 4:GA 解锁状态存档不完整
原因:玩家解锁的 GA 是 ASC 运行时状态,存档时漏了。
解法:保存 Tag 而不是 GA 引用(存 Ability.Unlocked.Fireball 这种 Tag,加载时按 Tag 重新 GiveAbility)。Tag 是持久化的语言,引用不是。
相关节点速查
- 任务表:
DT_Quest+ 目标结构 - 存档:
USaveGame+SaveGameToSlot - GA 解锁存 Tag:
Ability.Unlocked.XXX
与第 5 章的桥
任务与存档是联机复制的数据源头:任务状态、存档结构、GA Tag——这些"数据"下一章会被联机系统复制与同步。把数据结构先理清,网络才好做。
心得:我们项目有一次更新让存档结构改了字段,没做迁移,一个老玩家的存档崩了——他愤怒地在群里说"我肝了 20 小时"。从那以后版本号+迁移+双槽位成了铁律。玩家的存档是托付给你的二十年人生。
