ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity3D RPG 从零搭建:战斗、场景切换与存档

Unity3D RPG 从零搭建:战斗、场景切换与存档

把一个 RPG 从空项目推到"能走、能砍、能换场景、能读档"的状态,是很多人入行的第一道坎。我第一次做这件事的时候,光是"为什么我的角色挥刀没有伤害"就卡了整整一个周末,最后发现是动画事件挂在了一个被禁用的骨骼节点上。这篇就聊 Unity3D 下 RPG 的初始环境搭建、战斗系统、场景切换与存档这四件事:从工程目录怎么分、战斗数值按什么口径算,到异步加载怎么不卡帧、存档怎么写到一半断电也不炸文件。适合刚上手 Unity3D 想做完整 RPG 循环的朋友,也适合已经能写点脚本、但系统一多就乱成一锅粥的同学。我不打算讲"什么是 GameObject"这种内容,默认你能看懂 C# 和基本的引擎面板,重点放在那些文档里不写、但你一定会撞上的地方。

1. 开局三件事:版本、目录、场景

1.1 引擎版本与渲染管线的取舍逻辑

第一个决策不是写代码,是选版本。我的建议很明确:用官方的 LTS 长期支持版,别追最新预览版。原因不在于新版本不好,而在于 RPG 这种项目周期长、依赖库多,预览版里某个 API 签名变动,可能让你三天前写的战斗模块全部编译报错。LTS 版本经过大半年的社区验证,你遇到的坑基本都有人踩过,搜索引擎里能找到答案,这个隐性成本比新特性值钱得多。

渲染管线的选择同样要想清楚。内置管线胜在资料多、任何老旧教程都能直接照抄,但光照表现和后期效果明显吃力;通用渲染管线(URP)在移动端性能友好、光照配置直观,代价是材质升级后部分内置着色器不可用,一些老插件会直接紫屏。我这边做的是中小规模的 RPG,最终选了 URP,因为角色数量多、需要批量合批,URP 的 SRP Batcher 在同屏几十个怪的场景里收益很实在。如果你的项目目标是纯 PC 单机、画面要求高、又不想折腾管线迁移,内置管线也不是不能选,只是后期想换会很痛。

提示:管线一旦定了就别中途改。真要改,务必在美术资源大规模生产之前,否则几百个材质的升级返工能让人怀疑人生。

项目模板上,别用带一堆示例资源的模板开局,选最干净的空模板,然后自己按需安装包。开箱模板里的示例场景、示例脚本、示例输入配置,后期清理起来比重建还费劲,尤其是那套示例输入配置,会和你后面自己配的按键映射打架。

1.2 目录结构与工程规范:先立规矩再写代码

我见过太多做到第三个月就找不到文件的项目。目录规范这件事,第一天不做,后面就是灾难。我的做法是在 Assets 下建一个下划线开头的项目根目录,把所有自己写的东西塞进去,和引擎、第三方资源物理隔离:

Assets/ ├── _Project/ │ ├── Scripts/ │ │ ├── Core/ // 启动、全局管理器、事件总线 │ │ ├── Combat/ // 战斗相关 │ │ ├── Save/ // 存档 │ │ ├── SceneFlow/ // 场景加载 │ │ └── UI/ │ ├── Art/ │ │ ├── Characters/ │ │ ├── Environment/ │ │ └── VFX/ │ ├── Audio/ │ ├── Data/ // 配置表、ScriptableObject 资产 │ ├── Prefabs/ │ ├── Scenes/ │ └── Settings/ ├── ThirdParty/ // 外部资源,永不修改 └── Plugins/

这个结构的核心思想只有一个:我的东西和别人的东西永远不混放。下划线前缀保证_Project永远排在资源列表最上面,找东西时不用滚动。ThirdParty目录里的内容我一个字符都不改,需要调整就在外面包一层适配脚本,这样将来升级库版本时直接整个目录替换,不会丢改动。

另一件值得第一天就做的事是程序集定义(Assembly Definition)。把 Core、Combat、UI 拆成独立程序集,好处有两个:一是编译速度,改一行 UI 代码不用把整个项目重编;二是强制约束依赖方向——UI 可以引用 Combat,Combat 绝对不能引用 UI。这条约束会在你写到第二十个小时的时候救你一命,因为它从编译期就阻止你把表现逻辑塞进战斗计算里。

命名上我用的规则很土但很有效:脚本名等于类名,一个文件一个公开类,预制体名和主脚本名一致,场景名用英文加编号(Level_01)。听起来像废话,但当你在搜索框里输入Player弹出十七个结果的时候,就会感谢当初那个坚持命名的自己。

1.3 场景清单与最小可玩闭环

RPG 的场景不止是关卡。我一开始只建了一个测试场景,结果做到一半发现"角色数据需要跨场景保留"这件事根本没有落点,只能推倒重来。正确的做法是在开工时就规划出这条链路:

场景职责关键内容
Boot全局初始化,只加载一次常驻管理器、存档读取入口、设置加载
MainMenu主菜单、新建/继续游戏UI 交互,不承载任何战斗逻辑
Level_01第一张可玩地图出生点、敌人、可交互物
Level_02第二张地图用于验证跨场景数据传递

为什么要有一个独立的 Boot 场景?因为 RPG 里有一批对象需要"永远活着"——背包数据、玩家属性、音频管理、场景加载器。把这些做成预制体放在 Boot 场景里,用DontDestroyOnLoad保护起来,切场景时它们不受影响。如果直接在主菜单里做初始化,一旦玩家从主菜单反复进出,初始化逻辑就可能执行多次,产生重复的监听器、重复的单例,表现为"打一下怪掉两次血"这种灵异现象。

最小可玩闭环的判定标准,我给一个很具体的清单:角色能用键盘移动、能攻击、敌人有血量并会对玩家造成伤害、敌人死亡后消失、能从第一张图走到第二张图并保留当前血量、退出游戏重进能恢复到存档位置。这六条全部通过,才叫"骨架跑通了",可以开始往里填内容。少任何一条,后面加系统都会带着隐患。

2. 战斗系统:先数据,后表现

2.1 属性结构与伤害公式的口径设计

新手最容易犯的错是先做特效和动画,最后才想起来"伤害怎么算"。我的顺序永远反过来:先把数据结构定下来,再让表现去消费数据。因为表现是随时可以换的(换模型、换特效、换动画),而数值口径一旦被后续几十个技能依赖,改动成本会指数级上升。

角色的战斗属性我用一个可序列化的结构体承载,注意这里是用结构体而不是类,因为它需要频繁拷贝快照(比如"中毒前的属性"这种临时状态):

[System.Serializable] public struct CombatStats { public int maxHp; public int maxMp; public int attack; // 物理攻击 public int defense; // 物理防御 public float critRate; // 暴击率 0~1 public float critDamage; // 暴击倍率,默认 1.5 public float attackSpeed; }

伤害公式是最需要反复琢磨的地方。我试过三种,说说各自的坑。

第一种是纯减法:伤害 = 攻击 - 防御。直观、好理解、玩家一眼能算明白。问题是当防御接近攻击时伤害趋近 0,会出现"打不动"的挫败感;防御超过攻击还会算出负数,得强制Mathf.Max(1, ...)兜底,然后数值策划就得小心翼翼地控制防御成长曲线,非常不方便。

第二种是纯百分比减伤:减伤率 = 防御 / (防御 + K)。这个公式的好处天然不会出现负数和零伤害,防御的收益是递减的,越堆收益越低,天然抑制"全点防御"的极限流派。K 是常数,控制减伤曲线的陡峭程度,K 取 100 时,100 点防御对应 50% 减伤。

第三种是我最终用的混合式:

public static int CalculateDamage(CombatStats attacker, CombatStats defender, float skillMultiplier = 1f) { float mitigation = defender.defense / (defender.defense + 100f); float raw = attacker.attack * skillMultiplier * (1f - mitigation); bool isCrit = Random.value < attacker.critRate; if (isCrit) raw *= attacker.critDamage; return Mathf.Max(1, Mathf.RoundToInt(raw)); }

混合式的意思是:减伤用百分比曲线保证平滑,但结果先取整、再做最低值兜底。Mathf.Max(1, ...)保证任何情况下至少有 1 点伤害,玩家永远不会遇到"打了等于没打"。取整放在乘完所有系数之后,而不是在中间步骤取整——中间取整会产生累计误差,一个 1.4 倍技能加成可能因为提前取整变成 1.3 倍的实际效果,数值表就对不上了。

注意:暴击判定用Random.value < critRate,Random.value返回 [0,1] 闭区间,理论上等于 1 的情况极罕见,实际不影响。但如果你自己实现随机函数,务必确认区间是左闭右开还是双闭,这个细节在暴击率设成 1.0(必暴)的时候会暴露出来。

数值口径定完,还要定一件事:伤害是即时结算还是延迟结算。即时结算就是按下攻击键瞬间扣血,延迟结算是在动画的攻击帧触发。必须选延迟,否则会出现"角色还在抬手,怪已经死了"的诡异画面。这就要用到动画事件或者时间轴回调,具体放在 2.3 讲。

2.2 用状态机管住角色行为

RPG 角色的行为本质上是有限的几个状态之间的迁移:待机、移动、攻击、受击、死亡。不用状态机硬写 if-else 的后果是,代码会迅速膨胀成一棵谁也不敢碰的嵌套判断树,改一个地方崩三个地方。我第一版就是那么写的,最终Update里有四十多行嵌套,加个"闪避"需求花了整整一天。

我的做法是用一个极简的枚举状态机,不引入任何第三方框架,因为对这种五六个状态的角色来说,重型状态机框架反而是负担:

public enum ActorState { Idle, Move, Attack, Hurt, Dead } public class ActorStateMachine { public ActorState Current { get; private set; } = ActorState.Idle; public event System.Action<ActorState, ActorState> OnStateChanged; private readonly System.Collections.Generic.HashSet<ActorState> _locked = new(); public bool CanTransition(ActorState next) { if (Current == ActorState.Dead) return false; // 死了不接受任何迁移 if (_locked.Contains(Current)) return false; // 当前状态是"不可打断"的 if (next == ActorState.Hurt && Current == ActorState.Attack && _locked.Contains(ActorState.Attack)) return false; return true; } public void Transition(ActorState next) { if (next == Current) return; if (!CanTransition(next)) return; var prev = Current; Current = next; OnStateChanged?.Invoke(prev, next); } public void Lock(ActorState s) => _locked.Add(s); public void Unlock(ActorState s) => _locked.Remove(s); }

这里有几个设计决定值得解释。

为什么"死亡"是不可逆的硬编码?因为复活应当走独立的流程(重新初始化整个角色对象),而不是把状态从 Dead 改回 Idle。如果允许从 Dead 迁移出去,你会遇到"角色死了但动画还在跑、碰撞体没关、还被索敌"这一整串问题。把死亡做成终态,复活时直接重建,逻辑最干净。

为什么要"锁定"机制?因为攻击状态通常带两段不可打断的窗口——前摇和后摇。前摇不能被移动打断(否则可以靠移动取消前摇,无伤输出),后摇也不能随便打断(否则动作没有重量感)。锁定用集合而不是布尔值,是因为可能存在多重锁定,比如"攻击中"同时也"被剧情锁定操作",解锁要按顺序来,用布尔值会互相覆盖。

为什么用事件而不是让外部轮询状态?动画控制器、特效系统、UI 血条都需要知道状态变化,如果让它们每帧去if (state == Attack),逻辑会散落各处且时序不可控。事件驱动可以保证"状态一变化,所有订阅者按注册顺序收到通知",顺序确定,调试时好定位。

具体的攻击流程就是:按下攻击键,先检查CanTransition(Attack),通过则进入攻击状态、锁定、播放攻击动画;动画到攻击帧通过事件回调执行伤害判定;动画结束解锁、回到 Idle。这套下来,行为是收敛的,不会出现状态互相打架。

2.3 命中判定:攻击盒体与时间窗口

这一节是最容易出 bug 的地方。我列几个我踩过的具体坑,顺便讲正确做法。

坑一:用碰撞体触发判定。很多人第一反应是给武器挂一个 Collider,设成触发器,靠OnTriggerEnter判定命中。问题在于物理引擎的触发检测依赖刚体和帧率,快速挥刀时武器可能一帧从敌人身前扫到身后,中间完全检测不到,这叫穿透。而且触发回调的执行顺序不确定,同一帧打两个敌人时伤害结算顺序随机,如果涉及死亡掉血这种联动,结果会不稳定。

正确的思路是"几何查询 + 时间窗口"。在攻击动画的命中帧,主动做一次区域查询:

public class MeleeHitBox : MonoBehaviour { [SerializeField] private float radius = 1.2f; [SerializeField] private float angle = 100f; // 扇形张角 [SerializeField] private LayerMask targetLayer; public void PerformHit(CombatStats attacker) { var origin = transform.position + transform.forward * 0.6f; var hits = Physics.OverlapSphere(origin, radius, targetLayer); foreach (var col in hits) { var toTarget = col.transform.position - transform.position; if (Vector3.Angle(transform.forward, toTarget) > angle * 0.5f) continue; var receiver = col.GetComponent<IDamageable>(); if (receiver == null) continue; int dmg = DamageCalculator.CalculateDamage(attacker, receiver.Stats); receiver.TakeDamage(new DamageInfo(dmg, transform.position, DamageType.Physical)); } } }

扇形判定用OverlapSphere拿候选再算夹角过滤,比用碰撞体触发器可靠得多,因为它是在确定的时刻做一次确定的查询,和物理帧率无关。半径和角度这两个参数需要配合角色的攻击距离调:单手剑大概 1.2 米半径、100 度扇形,长枪可以到 2.5 米但角度收到 60 度,重锤角度可以开到 140 度但半径小。这些数值别凭空拍,先在场景里用OnDrawGizmos画出判定范围,看着角色模型调,调完再录下来对比手感,这一步省不了。

private void OnDrawGizmosSelected() { Gizmos.color = new Color(1f, 0.4f, 0f, 0.35f); Gizmos.DrawWireSphere(transform.position + transform.forward * 0.6f, radius); Gizmos.DrawRay(transform.position, Quaternion.Euler(0, -angle * 0.5f, 0) * transform.forward * radius); Gizmos.DrawRay(transform.position, Quaternion.Euler(0, angle * 0.5f, 0) * transform.forward * radius); }

坑二:同一个攻击命中同一目标多次。如果命中窗口持续时间大于一帧,而你在Update里持续做查询,一个敌人会被打三四次。解决办法是维护一个本次攻击已命中的目标集合,攻击开始时清空:

private readonly HashSet<IDamageable> _hitThisSwing = new(); public void BeginSwing() => _hitThisSwing.Clear(); public void TryHit(IDamageable target) { if (!_hitThisSwing.Add(target)) return; // 已命中,跳过 // ... 结算伤害 }

坑三:手感生硬。判定完全精确反而不好玩。真实手感需要三个东西:命中帧前后各留一点容错(判定窗口比动画看起来长两三帧)、命中时有打击停顿(命中瞬间把时间缩放拉到 0.05 持续 3~5 帧再恢复)、以及受击方的击退和顿帧。这三样加起来,玩家才会觉得"刀砍进去了"。我第一版没有打击停顿,测的时候总觉得砍空气,加上之后同一个动画的手感完全不同。

提示:打击停顿改的是Time.timeScale,会影响所有基于时间的逻辑。如果你有 UI 动画或者加载进度条,记得把它们改成unscaledDeltaTime,否则停顿时界面也会卡住。

攻击输入的处理也值得说一句。玩家按下攻击键的时机往往早于角色能接受输入的时机(比如还在后摇里),如果直接丢弃这次输入,手感会很"粘"。正确做法是做一个短暂的输入缓冲:把按键请求记下来,在接下来 0.2 秒内如果状态变为可攻击,就补执行这次攻击。这个改动只有十几行代码,但手感提升非常明显,是所有动作向 RPG 的标配。

2.4 事件解耦:让 UI 和特效自己来订阅

战斗结算完成之后,需要更新血条、播放飘字、播放受击特效、播放音效、判断死亡。如果把这些写进TakeDamage里,这个方法会越来越长,最后变成谁也不愿意碰的上帝方法。

我的做法是定义一个全局静态事件总线,专门转发战斗事件:

public readonly struct DamageInfo { public readonly int amount; public readonly Vector3 hitPoint; public readonly DamageType type; public DamageInfo(int amount, Vector3 hitPoint, DamageType type) { this.amount = amount; this.hitPoint = hitPoint; this.type = type; } } public static class CombatEvents { public static event System.Action<IDamageable, DamageInfo> OnDamageDealt; public static event System.Action<IDamageable> OnActorDied; public static void RaiseDamage(IDamageable target, DamageInfo info) => OnDamageDealt?.Invoke(target, info); public static void RaiseDeath(IDamageable target) => OnActorDied?.Invoke(target); }

这里把事件定义成静态的,好处是不需要拿到任何引用就能订阅,UI 面板在自己的OnEnable里订阅、OnDisable里退订,和战斗系统零耦合。坏处是必须在OnDisable里退订,否则对象销毁后事件还持有它的引用,会引发空引用异常甚至内存泄漏——这是静态事件最经典的坑,我在第一个项目里被它折磨了很久,表现为"切换场景后打怪报空指针"。

还有一个细节:用readonly struct而不是类来传递伤害信息,是为了避免每次攻击都产生一次堆分配。RPG 里战斗频率很高,一秒几十次伤害结算,如果每次都 new 一个类对象,GC 会在几十秒后集中触发一次,表现为周期性的掉帧。结构体是栈分配的,没有这个问题。

3. 场景切换:别让加载毁掉体验

3.1 为什么不能直接同步加载

SceneManager.LoadScene("Level_02")这一行代码确实能切场景,但它是同步阻塞的:整个主线程卡在那里等资源加载完,界面完全冻结。测试场景里资源少,你感觉不到;等地图铺满美术资源,玩家就会看到画面定格两三秒,像程序崩了一样。

更麻烦的是同步加载会丢掉所有过场信息。RPG 的场景切换通常需要:显示加载界面、展示当前章节名、淡入淡出、加载完成后把玩家放到正确出生点。这些都需要在加载过程中有帧可以执行,同步加载把帧全占了。

所以我的场景切换一律走异步加载 + 进度展示。核心是LoadSceneAsync返回的AsyncOperation,配合allowSceneActivation = false手动控制激活时机:

public class SceneLoader : MonoBehaviour { public static SceneLoader Instance { get; private set; } [SerializeField] private CanvasGroup loadingGroup; [SerializeField] private UnityEngine.UI.Slider progressBar; public string PendingSpawnId { get; set; } public bool IsLoading { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); loadingGroup.alpha = 0f; loadingGroup.blocksRaycasts = false; } public void LoadScene(string sceneName, string spawnId = "Default") { if (IsLoading) return; PendingSpawnId = spawnId; StartCoroutine(LoadRoutine(sceneName)); } private System.Collections.IEnumerator LoadRoutine(string sceneName) { IsLoading = true; yield return FadeCanvas(0f, 1f, 0.25f); // 淡入遮罩 var op = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync(sceneName); op.allowSceneActivation = false; float shown = 0f; while (op.progress < 0.9f) { // 真实进度只到 0.9,映射到 0~1 float target = Mathf.Clamp01(op.progress / 0.9f); shown = Mathf.MoveTowards(shown, target, Time.unscaledDeltaTime * 1.5f); progressBar.value = shown; yield return null; } // 资源已就绪,把剩余进度补满,避免进度条卡在 90% while (shown < 1f) { shown = Mathf.MoveTowards(shown, 1f, Time.unscaledDeltaTime * 2f); progressBar.value = shown; yield return null; } op.allowSceneActivation = true; while (!op.isDone) yield return null; yield return FadeCanvas(1f, 0f, 0.25f); // 淡出遮罩 IsLoading = false; } private System.Collections.IEnumerator FadeCanvas(float from, float to, float dur) { float t = 0f; loadingGroup.blocksRaycasts = to > 0.5f; while (t < dur) { t += Time.unscaledDeltaTime; loadingGroup.alpha = Mathf.Lerp(from, to, t / dur); yield return null; } loadingGroup.alpha = to; } }

几个关键点解释。

op.progress最大只到 0.9。这是引擎的固有行为,因为剩下 0.1 是场景激活阶段,只有在allowSceneActivation = true之后才会推进。所以映射进度时必须除以 0.9,否则进度条永远停在 90%,玩家会以为卡死了。这也是为什么加载界面通常要做"假进度"——真实进度到 90% 后,用一小段平滑动画补到 100%,让观感完整。

为什么要MoveTowards而不是直接赋值?因为op.progress的更新是跳跃的,可能一帧跳 15%,进度条直接弹过去,观感很差。用固定速度追上去,看起来才像"加载"而不是"闪一下"。

为什么淡入遮罩在加载开始前?因为加载过程中主线程有短暂卡顿(资源解压),如果先加载再遮罩,玩家会看到一瞬间的画面冻结。先盖上遮罩再开始加载,卡顿就被遮住了。这是一个几乎零成本但效果显著的技巧。

3.2 出生点与跨场景数据传递

切完场景后最经典的 bug 是:玩家出现在场景中央,或者直接从天上掉下去。原因是新场景加载后,玩家对象需要被放到指定位置。

我的方案是在目标场景里放若干个空物体作为出生点,用名字标记:

public class SpawnPoint : MonoBehaviour { [SerializeField] private string spawnId = "Default"; public string SpawnId => spawnId; } // 场景加载完成后执行的代码 public class SpawnHandler : MonoBehaviour { private void OnEnable() { UnityEngine.SceneManagement.SceneManager.sceneLoaded += HandleSceneLoaded; } private void OnDisable() { UnityEngine.SceneManagement.SceneManager.sceneLoaded -= HandleSceneLoaded; } private void HandleSceneLoaded(UnityEngine.SceneManagement.Scene scene, UnityEngine.SceneManagement.LoadSceneMode mode) { if (mode != UnityEngine.SceneManagement.LoadSceneMode.Single) return; var player = GameObject.FindWithTag("Player"); if (player == null) return; var points = FindObjectsOfType<SpawnPoint>(); var target = System.Array.Find(points, p => p.SpawnId == SceneLoader.Instance.PendingSpawnId) ?? System.Array.Find(points, p => p.SpawnId == "Default"); if (target != null) { player.transform.position = target.transform.position; player.transform.rotation = target.transform.rotation; } } }

用sceneLoaded事件而不是在协程里直接设置位置,是因为场景加载完成和对象初始化完成是两回事。如果在协程的while (!op.isDone)之后立刻设置位置,新场景里的物理系统可能还没准备好,位置会被物理引擎的碰撞解算推走。用事件回调能保证在场景所有对象Awake/OnEnable之后执行,时序更可靠。

至于跨场景的数据传递,原则是:场景之间不直接引用,所有共享状态放在常驻的全局对象里。玩家血量、背包、任务进度这些数据,全部挂在一个不销毁的GameSession单例上,场景只负责"展示",不负责"持有"。这样即使玩家在一个场景里死了重开,数据也可以选择保留或重置,不会因为场景卸载而丢失。

注意:FindObjectsOfType在场景加载时调用一次问题不大,但千万别放在Update里。这个 API 会遍历整个场景层级,同屏对象上千时开销非常可观。如果是频繁查找,改用注册表模式,对象在OnEnable时把自己登记到一个静态字典里。

3.3 加载过程中的资源管理

异步加载还有一个容易被忽略的点:旧场景的卸载和新场景的加载是同一个操作,LoadSceneMode.Single会自动卸载旧场景。但如果你在旧场景里DontDestroyOnLoad了一些对象(比如特效、掉落物容器),它们会跟着走,久了就堆成一堆。

我的处理方式是给所有跨场景对象加一个统一的基类,负责在合适的时机清理自己:

public abstract class PersistentObject : MonoBehaviour { protected virtual void Awake() { transform.SetParent(null); DontDestroyOnLoad(gameObject); } protected abstract bool ShouldSurvive(string newSceneName); }

然后在场景加载事件里遍历所有PersistentObject,把不该活过这次切换的直接销毁。听起来有点绕,但比"到处手动销毁"可靠得多,因为你能在一个地方看清所有常驻对象的生命周期。

还有个性能上的小技巧:如果两张地图共享同一套角色和 UI 资源,可以在加载前用Resources.UnloadUnusedAssets清一次,但要放在加载之后而不是之前——放在之前会把马上要用的资源也清掉,反而增加加载时间。这个 API 会触发一次同步的垃圾回收,大项目里可能卡几百毫秒,最好配合加载界面使用。

4. 存档系统:从存什么到怎么安全地写

4.1 存档边界:什么该存,什么绝对不存

存档设计的第一原则不是技术,是划清边界。我见过把整个场景对象序列化进去的方案,结果存档文件几十兆,读档还要三秒。正确的做法是只存"必要且不可再生"的数据。

我的划分是这样的:

类别存不存理由
玩家等级、经验、属性点存玩家投入的时间,绝对不可丢
当前血量/蓝量存影响读档后的战斗状态
背包物品与数量存同上
装备穿戴状态存同上
已完成任务标记存靠它判断任务能否接取
场景名 + 出生点 ID存用于恢复位置
敌人当前血量不存读档后敌人重置满血,玩家反而受益
场景里掉落的临时物品不存会导致存档膨胀和数据不一致
特效、音效状态不存纯表现,无意义

用数据类来承载,全部字段必须是可序列化的基础类型或它们的容器:

[System.Serializable] public class SaveData { public int version = 1; public long timestamp; // Unix 秒,用于显示存档时间 public string sceneName; public string spawnId; public int level; public int exp; public int hp; public int mp; public System.Collections.Generic.List<ItemStack> inventory = new(); public System.Collections.Generic.List<string> equippedIds = new(); public System.Collections.Generic.List<string> completedQuests = new(); public float playerX, playerY, playerZ; // 不用 Vector3,避免序列化兼容问题 } [System.Serializable] public class ItemStack { public string itemId; public int count; }

为什么坐标拆成三个 float 而不是直接存 Vector3?因为Vector3的序列化依赖引擎内部实现,不同版本之间字段可能变化,而且用其他工具(比如外部编辑器、云端校验)读写时不好解析。拆成基础类型虽然难看,但兼容性最好。经验是:存档结构里只放基础类型和它们的容器,一个引擎类型都不要放。这条规则能避免九成的"换版本读不出档"问题。

另外警告一句:绝对不要序列化 MonoBehaviour 的引用,也不要存 GameObject 的实例 ID。这些 ID 在下次运行时会全部变化,读出来就是一串无效引用。物品、任务、技能都用字符串 ID 表示,读到档之后靠配置表去还原。

4.2 序列化方案与安全写入

序列化格式我选 JSON。二进制格式体积小、读取快,但调试时读不出来;JSON 用文本编辑器就能看,出问题一眼定位,对一个中小项目来说,可维护性远比那几 KB 的体积重要。

引擎自带的 JSON 工具对字典支持不好(Dictionary不会正确序列化),所以我的做法是数据结构里全用 List,需要字典语义时在读档后自己转换:

public class SaveSystem { private static string PathFor(int slot) => System.IO.Path.Combine(Application.persistentDataPath, $"save_{slot}.json"); public static bool Save(SaveData data, int slot) { try { data.timestamp = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds(); string json = UnityEngine.JsonUtility.ToJson(data, true); string finalPath = PathFor(slot); string tempPath = finalPath + ".tmp"; string backupPath = finalPath + ".bak"; System.IO.File.WriteAllText(tempPath, json); // 原子替换:先转正,再把旧文件挪成备份 if (System.IO.File.Exists(finalPath)) { if (System.IO.File.Exists(backupPath)) System.IO.File.Delete(backupPath); System.IO.File.Move(finalPath, backupPath); } System.IO.File.Move(tempPath, finalPath); return true; } catch (System.Exception e) { UnityEngine.Debug.LogError($"存档失败: {e.Message}"); return false; } } public static SaveData Load(int slot) { string path = PathFor(slot); if (!System.IO.File.Exists(path)) { string backup = path + ".bak"; if (System.IO.File.Exists(backup)) path = backup; else return null; } try { string json = System.IO.File.ReadAllText(path); var data = UnityEngine.JsonUtility.FromJson<SaveData>(json); return Migrate(data); } catch (System.Exception e) { UnityEngine.Debug.LogError($"读档失败: {e.Message}"); return null; } } }

这段代码里最重要的是先写临时文件再原子替换。直接覆盖原文件的风险是:写到一半手机没电、或者玩家强杀进程,文件会变成半截 JSON,下次读档直接解析失败,存档就废了。写临时文件、校验、再改名,这个顺序保证你永远有一个完整的旧版本兜底。多花的那几毫秒,换的是玩家几十小时进度的安全,不亏。

那个.bak备份也不是多余的。我在测试时故意用任务管理器杀掉进程,验证过:如果损坏发生在改名之前,原文件还是完整的;如果发生在改名之后,.bak里还有上一份。读档时发现主文件读不出来就自动回落备份,双重保险。

提示:Application.persistentDataPath在不同平台上指向不同位置,而且可能被系统清理(比如某些平台的缓存策略)。如果项目对存档有更高要求,需要额外做云端同步或者手动导出,不要假设本地文件永久有效。

4.3 版本号与后续兼容

version字段是给未来的自己留的后路。游戏上线之后,存档结构几乎一定会变——加新职业、加新装备栏、改属性算法。如果不做版本控制,老玩家的存档读进来就会缺字段,表现为属性全 0、背包空白。

我的做法是每次结构调整就把version加一,然后写一个迁移函数:

private static SaveData Migrate(SaveData data) { if (data == null) return null; // 从 v1 到 v2:新增了背包容量字段 if (data.version < 2) { data.inventory ??= new System.Collections.Generic.List<ItemStack>(); data.version = 2; } // 从 v2 到 v3:玩家坐标改为场景相对坐标,这里做一次性换算 if (data.version < 3) { data.spawnId ??= "Default"; data.version = 3; } return data; }

关键点是迁移要能连续执行,而不是只判断两个版本。老玩家可能从 v1 直接跳到 v3,代码要能顺着 1→2→3 一路走下来。所以每一段迁移只负责"从上一版到本版",绝不跳步。这个习惯养成之后,加字段几乎零成本。

另外,所有新增字段都要给默认值,不能依赖"读档时自动初始化"——如果老存档里没有这个字段,反序列化后它就是 null 或 0,你得在迁移里显式补上。特别是 List,null 和空列表在后续逻辑里表现完全不同,null 会直接抛空引用。

4.4 存档时机与自动存档设计

什么时候存?自动存档的时机选择直接影响玩家体验。我的策略是三类:

第一类是关键节点必存:进入新场景、击败 Boss、完成任务、获得稀有物品。这几个时刻都是"玩家不想重来"的位置,存了有安全感。

第二类是周期兜底:每三到五分钟存一次。防止玩家在野外跑了半小时突然崩溃,进度全丢。但要注意别在战斗高潮里存,因为存档的瞬间会有一点点卡顿,最好在战斗结束后延迟十几秒再存。

第三类是手动存档:在菜单里给玩家一个明确的保存按钮。有些玩家就是喜欢自己掌控,给他这个权利。

具体实现上,自动存档我用一个协程配合脏标记:

public class AutoSave : MonoBehaviour { [SerializeField] private float interval = 180f; private float _timer; private bool _dirty; public void MarkDirty() => _dirty = true; private void Update() { _timer += Time.unscaledDeltaTime; if (_timer < interval || !_dirty) return; _timer = 0f; _dirty = false; SaveSystem.Save(GameSession.Instance.BuildSaveData(), GameSession.Instance.CurrentSlot); } }

用unscaledDeltaTime是为了游戏暂停时也能计时,但更稳妥的做法是让计时在游戏暂停时也走——很多游戏在菜单里停留很久,出来就想让存档生效。

注意:存档写入是同步的文件 IO,虽然有临时文件保护,但如果存档内容很大(比如十几兆),在移动端会有明显的卡顿。真要优化,可以用异步线程写文件,但必须保证写完之后才允许下一次存档,否则两次写入会互相覆盖。我用的是简单的"写入中禁止再次写入"标志位,够用且不出错。

5. 踩坑速查:这些坑我一个个撞过来的

5.1 高频问题速查表

现象大概率原因排查方向
攻击没伤害动画事件挂在了被禁用的节点上检查事件接收脚本所在的 GameObject 是否 Active
一次攻击掉多段血命中窗口内重复判定加已命中目标集合,攻击开始时清空
切场景后 UI 消失UI 挂在旧场景下被卸载把 UI 根节点设成常驻
切场景后角色瞬移出生点在场景加载完成后才设置用 sceneLoaded 事件设置位置
读档后属性全 0结构变化但没做版本迁移加 version 字段并写迁移逻辑
存档丢失覆盖写过程中被中断改用临时文件 + 原子替换
加载进度条卡在 90%没处理 allowSceneActivation手动映射进度并补满
打怪时报空引用静态事件没在 OnDisable 退订检查所有订阅点是否配对退订
帧率周期性掉战斗逻辑频繁 new 对象改结构体、加对象池
敌人打不死伤害被取整成 0加 Mathf.Max(1, ...) 兜底

5.2 编辑器内正常、真机上出问题的典型差异

这几处差异我每一个都踩过,值得单独拎出来。

路径大小写。编辑器在部分系统上对文件名大小写不敏感,真机可能敏感。资源名随手写成Player_atk和player_atk混用,编辑器里能加载,打包后直接找不到资源。规矩是全部小写下划线,从第一天就统一。

帧率差异。编辑器里跑 200 帧,真机锁 60 帧,所有基于"每帧做一点"的逻辑都会变慢。比如用Update里累加来做动画插值、或者用帧数做计时,真机上进度条会明显变慢。解决办法是全部改成Time.deltaTime驱动,绝不用帧数当时间单位。

文件权限。编辑器里Application.persistentDataPath是个普通目录,随便读写;某些平台上这个目录有额外限制,外部直接访问需要用系统提供的接口。所以不要在代码里硬编码绝对路径,一律用引擎提供的路径 API 拼接。

日志性能。编辑器里Debug.Log几乎无感,真机上高频打印日志能直接把帧率打下来。上线前把日志封装一层,加个开关,正式包只保留错误级别。

5.3 调试技巧与工程习惯

最后分享几个真正省时间的习惯。

给每个系统留一个调试面板。我在场景里挂了一个隐藏的调试 UI,按一个键调出来,上面有"给玩家加一万金币""直接跳到第二关""强制存档/读档""杀掉所有敌人"这些按钮。听起来是额外工作量,但它让我测存档逻辑的时间从每次三分钟缩短到十秒。测二十次就回本了。

善用自定义 Gizmos。攻击范围、出生点、敌人巡逻路径、触发区域,全部画出来。所有"为什么打不到"的问题,打开 Scene 视图看一眼就清楚了。花十分钟写画线代码,能省掉几小时的瞎猜。

状态机的当前状态要可见。角色头顶挂一个小标签显示当前状态枚举,或者干脆在 Inspector 里做一个只读字段。我在调受击打断的时候,因为看不到状态变化,硬是加了二十几个日志才定位到问题,事后发现做个可视状态标签十分钟就能解决。

版本控制从第一天开始,并且忽略正确。提交前确认Library、Temp、Logs、obj这些目录都在忽略列表里。我见过有人把Library提交上去,仓库直接涨到几个 G,清理起来非常麻烦。另外场景文件冲突几乎无法合并,多人协作时约定好每人负责的场景,避免同时改同一个。

最后一条,也是最重要的:把"最小可玩闭环"尽快跑通,再开始美化。我第一个 RPG 项目在美术上花了两个月,结果战斗系统和存档完全没做,做到最后发现数据流设计有问题,所有美术资源都要重新挂一遍。先用胶囊体和方块把玩法跑通,确认数据流、状态机、存档结构都合理了,再往里填皮。顺序反过来,返工的成本是数倍的。

现在我的习惯是每做一个新系统,先写一个"丑但完整"的版本,跑通之后再花时间打磨。上面这套战斗、场景、存档的骨架,我已经在三个小项目里复用过了,改的只是数值和资源,结构一次都没动过。这也是我为什么建议先把这套东西吃透——它不是一个教程,是一个可以直接搬走的地基。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进