勇士传说学习心得
一、项目整体架构
总体分为两个部分:持久化场景与动态加载场景
持久化场景(初始化场景,不卸载)
├── SceneLoaderManager ── 协管场景加载、持有 DataManager
├── UIManager ── 监听场景事件,按需显隐 UI
├── DataManager ── 存档系统中枢(单例)
└── EventSystem ── 全局事件
动态加载场景(Additive)
├── 主菜单
├── Level_01 / Level_02 / ...
└── Boss 房间
架构设计思想:
1. 事件驱动:ScriptableObject 作为事件通道解耦模块
2. 单例 + 服务定位:DataManager、SceneLoader 等全局唯一访问
3. 接口隔离:ISavable 接口统一存档行为
4. 状态模式:敌人 AI(BaseState → 具体状态类)
二、场景加载系统
其中几个关键代码逻辑:
---SceneLoader (挂载在持久化场景中):
1.持有多个 SceneLoadEventSO(ScriptableObject 事件实例),在 Awake 时给各个 SO 订阅回调函数
2.保存各个加载状态数据:如isLoading / currentScene
--- SceneLoadEventSO(用作场景间通信的 ScriptableObject)
1.LoadRequestEvent 携带 (场景SO, 出生坐标, 是否渐入) 参数
2.SceneUnloadEvent 卸载前广播,UIManager 监听
3.AfterSceneLoaded 加载完成后广播,摄像机等模块监听
4. NewGameEvent 新游戏专用,携带首个场景+出生坐标
以玩家进入传送门为例,展示完整流程链:
1.玩家进入传送门并点击传送键:传送门脚本调用: loadRequestEventSO.RaiseEvent(sceneSO, pos, fadeOut)
2.SceneLoader 收到事件(订阅函数 OnLoadRequestEvent)
├── 检查 isLoading → true 则 return(防止重复加载)
├── 保存 sceneToLoad / posToGo / fadeScreen 到成员变量
├── isLoading = true
├── 锁定玩家操作
└── 启动协程 SwitchScene()
3.SwitchScene() 协程链
├── ① 渐出黑幕(FadeOut)
├── ② 广播 SceneUnloadEvent → UIManager 监听,决定隐藏哪些 UI
├── ③ 关闭玩家图片
├── ④ 卸载当前场景 currentScene.SceneReference.UnloadSceneAsync()
└── ⑤ 调用 LoadNewScene()
4. LoadNewScene()
├── loadingOption = sceneToLoad.SceneReference.LoadSceneAsync(Additive)
├── loadingOption.Completed += OnSceneLoadCompleted
└── 完成后广播 SceneLoadedEvent
5. OnSceneLoadCompleted()
├── currentScene = sceneToLoad
├── 设置 player.position = posToGo
├── 启动 ShowScene() 协程
└── 广播 AfterSceneLoadedEvent → 摄像机获取范围
6.ShowScene() 协程
├── 非主菜单 → 打开玩家图像 + 渐入 + 解锁玩家
└── isLoading = false
场景加载系统的优点:
1.ScriptableObject 做事件通道: 模块不需要互相引用,只依赖同一个 SO 资产,脱离场景生命周期,持久化存在
2.Additive 场景加载:主场景(持久化场景)始终存在,不销毁。 关卡场景 Additive 加载,卸载时只卸关卡。全局管理器(DataManager / UIManager)不丢失
三、蹬墙跳
逻辑实现:
if (isGround) → 普通跳跃(只加向上的力)
if (onWall) → 蹬墙跳(向水平反方向 + 向上加力),wallJump = true
OnWall判定:Physics2D.OverlapCircle+LayerMask检测半径碰到"地面"图层
玩家当前有水平输入(按住方向键)
玩家正在下落(rigidbody.velocity.y < 0)
wallJump 标记作用:
- 防止蹬墙跳后立刻往回走又贴墙,引发连续无限跳
- 可能配合短暂的方向锁
四、敌人状态机
基类 BaseState(抽象类):
OnEnter() 抽象方法,进入状态时调用一次
LogicUpdate() 抽象方法,在 Update 中每帧调用
PhysicsUpdate() 抽象方法,在 FixedUpdate 中每帧调用
OnExit() 抽象方法,离开状态时调用一次
在 Enemy 主类中:
LogicUpdate 在 Update() 中调用 currentState.LogicUpdate()
PhysicsUpdate 在 FixedUpdate() 中调用 currentState.PhysicsUpdate()
切换状态:先 currentState.OnExit() → currentState = newState → newState.OnEnter()
优点:状态模式比起if-else 堆砌来说:
状态独立成类,职责单一,易扩展(加新状态不需改旧代码)
符合开闭原则 OCP
五、存档系统:
---DataManager (单例)
┌─────────────────────┐
│ List<ISavable> │ ← 注册的所有可存档物体
│ Data savedData │ ← 唯一存档数据实例
│ │
│ RegisterData() │ ← 物体 Awake/Start 时调用
│ UnregisterData() │ ← 物体 OnDisable 时调用
│ Save() │ ← 遍历 List,每个物体写数据
│ Load() │ ← 遍历 List,每个物体读数据
└──┬──────────┬───────┘
│ │
┌─────────▼──┐ ┌───▼──────────┐
│ Player │ │ Enemy_01 │ ... 都实现 ISavable
│ (坐标/血量) │ │ (位置/状态) │
└────────────┘ └──────────────┘
---每一个需保存的物品实现接口:ISavable
- RegisterSaveData() 注册到 DataManager 的 List 中
- UnregisterSaveData() 从 DataManager 的 List 中移除
- GetSaveData(Data data) 将自己的状态写入传入的 Data 对象
- LoadData(Data data) 从 Data 对象读取状态,恢复自身
---Data 类(存档数据的容器
内部结构:多种字典,键统一为 GUID 字符串
Dictionary<string, Vector3> characterPosDict // 位置
Dictionary<string, float> healthDict // 血量
Dictionary<string, float> energyDict // 能量/法力
string currentScene // 当前所在场景名
键的生成:挂载在每个可存档物体上的 DataDefination 脚本
C# 运行时生成全局唯一的 GUID 字符串, 每个物体终生唯一 ID
--- 存档流程
[保存] 按 ESC 键触发:
1. 检测是否已在菜单界面 → 是则 return
2. 调用 DataManager.Instance.Save()
- 遍历 List 中所有 ISavable 实例
- 每个实例执行 GetSaveData(savedData)
→ 将自己的坐标、血量等写入 savedData 对应字典
3. 返回主菜单场景
4. 注:返回主菜单时旧场景场景卸载 → 所有 ISavable 物体 OnDisable()
→ UnregisterSaveData → List 清空
[加载] 点击"继续游戏":
1. SceneLoader 中触发 ContinueGame()
2. 如果 savedData.currentScene 为 null 或是主菜单 → 开新游戏
3. 否则加载存储的场景 → 走协程链 (渐出 → 卸载 → 加载 → 渐入)
4. isContinuedLoad = true(标识"本次是加载存档")
5. 等场景加载完毕 + AfterSceneLoaded 事件触发后
(此时新场景中的 ISavable 物体已完成 RegisterSaveData,List 已重新填充)
6. 检测 isContinuedLoad → 调用 DataManager.Instance.Load()
- 遍历 List 中所有 ISavable 实例
- 每个实例执行 LoadData(savedData)
→ 从字典中读取自己 GUID 对应的坐标、血量,覆盖当前值
7. isContinuedLoad = false
六、核心问题
1:用 ScriptableObject 做事件系统好处:
SO 脱离场景,可在 Inspector 可视化连接订阅关系;
单例需要 FindObjectOfType 或全局引用,耦合较重;
SO 天然支持多份实例(不同场景用不同事件配置)。
2: 状态机如果用 Animator 状态机(Mecanim)做和代码做有什么区别:
Mecanim 适合动画过渡,代码状态机适合 AI 决策(速度/视野/目标)。
本项目把两者结合:代码状态机控制行为逻辑,Animator 控制表现层动画。
3: 蹬墙跳怎么防止玩家对着墙无限跳?
设置 wallJump 标记,跳跃后短暂禁止水平输入或锁定方向,
并且 OnWall 需要 "玩家在下落" 才判定,上升到最高点后不再判定为贴墙。
4: Additive 加载多个场景内存怎么管理?
同一时间只加载一个关卡场景。加载新场景前先 UnloadSceneAsync 卸载旧场景,
避免 Accumulated(累积内存)。全局的管理器在持久化场景不受影响。
5: 怎么处理"加载存档"和"新游戏"的入口差异?
通过 isContinuedLoad 标记区分:
新游戏:直接加载第一个关卡,不执行 DataManager.Load()
加载存档:加载存储的场景,完成后执行 Load() 覆盖位置血量
