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 小时"。从那以后版本号+迁移+双槽位成了铁律。玩家的存档是托付给你的二十年人生。