Main 场景的游戏主控制器——装配 ECS 世界、加载所有 UI、控制游戏主循环。 共 101 行,是理解整个项目 ECS 启动链路 最关键的文件。以下从前端工程师视角,逐行拆解。
101 行,6 个方法。比 HomeRoot 复杂一个数量级,因为这里要初始化整个 ECS 世界。
using UnityEngine;
using UnityEngine.EventSystems;
// 训练工程游戏根节点(放在 Main 场景里)。
// 负责装配 ECS、加载关卡、创建 HUD。由主入口场景 Home 点击"开始游戏"后加载 Main 场景触发。
public sealed class GameRoot : MonoBehaviour
{
Contexts _contexts;
GameFeature _feature;
GameEnv _env;
void Awake()
{
_env = BuildEnv();
_contexts = Contexts.sharedInstance;
// 每次进入 Main 场景重置一份干净的 ECS 世界
_contexts.Reset();
_feature = new GameFeature(_contexts, _env);
_feature.Initialize();
EnsureEventSystem();
// HUD(命值 + 关卡号 + 返回)
var hudPrefab = Resources.Load<GameObject>("Prefabs/GameHud");
if (hudPrefab != null)
Instantiate(hudPrefab).GetComponent<GameHud>().Bind(_contexts);
else
Debug.LogError("HUD 预制体缺失...");
// Awards 业务模块视图
var toastPrefab = Resources.Load<GameObject>("Prefabs/AwardToast");
if (toastPrefab != null)
Instantiate(toastPrefab).GetComponent<AwardToast>().Bind(_contexts);
// 失败弹窗
var failPrefab = Resources.Load<GameObject>("Prefabs/LevelFailPopup");
if (failPrefab != null)
Instantiate(failPrefab).GetComponent<LevelFailPopup>().Bind(_contexts, RetryLevel);
else
Debug.LogError("失败弹窗预制体缺失...");
// 作弊调试面板
var cheatGo = new GameObject("CheatPanel");
cheatGo.AddComponent<CheatPanel>();
}
void Update()
{
_feature.Execute();
_feature.Cleanup();
}
void RetryLevel()
{
var current = _contexts.game.hasCurrentLevel
? _contexts.game.currentLevel.Value : 1;
_contexts.game.isLevelFailed = false;
_contexts.game.ReplaceCurrentLevel(-1); // 哨兵值
_contexts.game.ReplaceCurrentLevel(current);
}
GameEnv BuildEnv()
{
var env = new GameEnv();
var cam = Camera.main;
if (cam == null)
{
var camGo = new GameObject("Main Camera");
camGo.tag = "MainCamera";
cam = camGo.AddComponent<Camera>();
}
cam.orthographic = true;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = Theme.MainBackgroundColor;
cam.transform.position = new Vector3(0, 0, -10);
env.Camera = cam;
env.Root = new GameObject("LevelRoot").transform;
env.ArrowPrefab = Resources.Load<GameObject>("Prefabs/Arrow");
env.GridPrefab = Resources.Load<GameObject>("Prefabs/Grid");
return env;
}
static void EnsureEventSystem()
{
if (Object.FindObjectOfType<EventSystem>() == null)
{
var es = new GameObject("EventSystem");
es.AddComponent<EventSystem>();
es.AddComponent<StandaloneInputModule>();
}
}
}
| # | 方法 | 做什么 | 前端类比 |
|---|---|---|---|
| 1 | BuildEnv() | 创建运行环境:摄像机、关卡容器、箭头/点阵预制体 | 创建 app 容器 + 加载静态资源 |
| 2 | Awake() 前半 | 初始化 ECS 世界:Contexts、GameFeature | new Vuex.Store() / new Redux store |
| 3 | Awake() 后半 | 实例化 4 个 UI 预制体 + 作弊面板 | mount() 渲染子组件 |
| 4 | Update() | 每帧执行所有 System + 清理 | requestAnimationFrame 主循环 |
| 5 | RetryLevel() | 重开当前关卡(哨兵值技巧) | 重置 state 触发重新渲染 |
| 6 | EnsureEventSystem() | 同 HomeRoot,确保 UI 可点击 | 同 HomeRoot |
从字段声明到 RetryLevel 哨兵技巧,逐段覆盖全部 101 行。
第 8~10 行。
Contexts _contexts;
Contexts = Entitas 代码生成器(Jenny)自动生成的类。持有所有 ECS Context 的引用:contexts.game、contexts.input 等。
下划线前缀 _ = C# 团队编码约定:私有实例字段 用下划线命名。前端类比:#contexts(JS 私有字段)。
GameFeature _feature;
GameFeature = 这个项目自定义的类,继承 Entitas 的 Feature。它把所有 System 按顺序装在一起,提供 Initialize()、Execute()、Cleanup() 三个统一入口。
类比:Feature = Redux 的 combineReducers + Vuex 的 modules,把分散的逻辑聚合到一条执行流水线。
GameEnv _env;
GameEnv = 这个项目自定义的 依赖注入容器(DI Container)。只存 4 样东西:
Camera、Root(关卡物体父节点)、ArrowPrefab、GridPrefab。
第 65~90 行。创建摄像机、关卡容器、加载预制体引用。
GameEnv BuildEnv()
{
var env = new GameEnv();
返回类型是 GameEnv,不是 void。创建一个空的 GameEnv 对象,然后逐步填充。
var cam = Camera.main;
if (cam == null)
{
var camGo = new GameObject("Main Camera");
camGo.tag = "MainCamera";
cam = camGo.AddComponent<Camera>();
}
与 HomeRoot 不同:HomeRoot 只是「设置已有摄像机」,GameRoot 找不到就自己创建一个。
关键步骤:
camGo.tag = "MainCamera" — 设置 tag,这样以后 Camera.main 能找到它
AddComponent<Camera>() — 给空 GameObject 挂 Camera 组件,然后它会返回这个组件的引用
cam.orthographic = true;
orthographic = 正交投影(无透视)。2D 游戏标配。
设为 false 的话就是透视投影(3D 游戏用的),物体会近大远小。
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = Theme.MainBackgroundColor;
cam.transform.position = new Vector3(0, 0, -10);
设置纯色白底 + 摄像机位置。注意:Z = -10,摄像机放在画面后面 10 单位远。
2D 正交投影下 Z 值决定渲染层级,只要在物体后面就行。前端类比:z-index 或 transform: translateZ(-10px)。
env.Camera = cam;
把摄像机存到 GameEnv 里。之后任何 System 需要摄像机时通过 _env.Camera 就能拿到。
env.Root = new GameObject("LevelRoot").transform;
创建空 GameObject,名字 "LevelRoot",取其 Transform 组件存入 env。关卡加载后所有箭头、点阵都挂在这个空节点下,方便组织 Hierarchy。
env.ArrowPrefab = Resources.Load<GameObject>("Prefabs/Arrow");
env.GridPrefab = Resources.Load<GameObject>("Prefabs/Grid");
预加载箭头和点阵的预制体引用。存到 env 里,当 LevelStartSystem 加载关卡时直接用,不需要每关都 Resources.Load(很慢)。
return env;
返回组装好的 GameEnv。回到 Awake 里 _env = BuildEnv() 接收。
第 16~21 行,Awake() 中最关键的 5 行。
_contexts = Contexts.sharedInstance;
Contexts.sharedInstance = 全局唯一的 ECS Context 单例。
_contexts.game = 游戏数据的 Context(关卡、命值、奖励等都在这)
_contexts.input = 输入数据的 Context(鼠标点击、拖拽等)
项目目前只用了这两个 Context。
_contexts.Reset();
每次进 Main 场景都重置。因为 Contexts 是单例,用户可能需要「返回主菜单 → 再点开始游戏」,如果不 Reset,上次关卡的数据会残留。
_feature = new GameFeature(_contexts, _env);
创建 GameFeature。去看看 [GameFeature.cs](file:///Users/zl_bofeng/Documents/pending-dome/unity/demo/GameClientTrainning/Assets/GameBase/App/GameFeature.cs) 的构造函数,它会 Add() 接 Add(),把所有 System 按顺序串成流水线。
Add() 的顺序 = 执行顺序。存档最前(先恢复数据),销毁最后(收尾清理)。
_feature.Initialize();
调用所有 System 的 Initialize()。大部分 System 的 Initialize 是空的,但 SavegameSystem 会在这里 读 JSON 文件,恢复存档数据到 ECS 组件。
第 26~47 行。这个项目里 UI 加载的标准三板斧。
var hudPrefab = Resources.Load<GameObject>("Prefabs/GameHud");
从 Resources/Prefabs/GameHud.prefab 加载 HUD 预制体。
if (hudPrefab != null)
Instantiate(hudPrefab).GetComponent<GameHud>().Bind(_contexts);
这条链式调用是整个项目 UI 初始化的核心模式,拆开看:
| 步骤 | 代码 | JS 类比 |
|---|---|---|
| 1. 克隆 | Instantiate(hudPrefab) | prefab.cloneNode(true) |
| 2. 拿组件 | .GetComponent<GameHud>() | .querySelector('[data-component="GameHud"]') |
| 3. 绑定数据 | .Bind(_contexts) | .bindContexts(contexts) 类似 Vue 的 provide |
Instantiate 的返回值:克隆出来的 GameObject。但 Unity 不知道它上面挂了什么脚本,所以需要用 GetComponent<GameHud>() 来拿那个特定的 MonoBehaviour 脚本引用。
else
Debug.LogError("HUD 预制体缺失...");
如果 HUD 没加载到(Build UI Prefabs 没执行过),打错误日志。
var toastPrefab = Resources.Load<GameObject>("Prefabs/AwardToast");
if (toastPrefab != null)
Instantiate(toastPrefab).GetComponent<AwardToast>().Bind(_contexts);
AwardToast(奖励弹出提示),同样的三板斧模式。注意这里没有 else 错误日志——奖励提示是可选的 UI,没加载也不会影响游戏运行,所以不打日志。
var failPrefab = Resources.Load<GameObject>("Prefabs/LevelFailPopup");
if (failPrefab != null)
Instantiate(failPrefab).GetComponent<LevelFailPopup>().Bind(_contexts, RetryLevel);
失败弹窗有点不同:Bind 传了两个参数 —— _contexts 和 RetryLevel。第二个参数是回调函数,当用户点「重试」按钮时调用。
var cheatGo = new GameObject("CheatPanel");
cheatGo.AddComponent<CheatPanel>();
作弊面板不走预制体,直接在代码里创建空 GameObject + AddComponent。因为它是纯代码生成的(用反射收集所有作弊项加到按钮上),不需要可视化编辑。
第 49~53 行。
void Update()
{
_feature.Execute();
_feature.Cleanup();
}
整个游戏的「心脏跳动」就在这两行。每帧都会执行:
| 调用 | 做什么 | 前端类比 |
|---|---|---|
Execute() | 跑一遍所有 System。按 GameFeature 里 Add 的顺序依次调用每个 System 的 Execute() | Redux dispatch 链 / Vuex action 链 |
Cleanup() | 清理单帧数据。输入事件只存在一帧,下一帧就清掉。标记 Destroy 的 Entity 也在这里删掉 | 批量 clear 临时状态 / GC |
第 56~63 行。精巧的「通过设 -1 再设回来触发 System」的模式。
void RetryLevel()
{
var current = _contexts.game.hasCurrentLevel
? _contexts.game.currentLevel.Value : 1;
_contexts.game.isLevelFailed = false;
_contexts.game.ReplaceCurrentLevel(-1);
_contexts.game.ReplaceCurrentLevel(current);
}
这段代码看着绕,但思路很聪明:
| 步骤 | 代码 | 为什么 |
|---|---|---|
| 1 | var current = ... | 记下当前第几关(比如第 3 关) |
| 2 | isLevelFailed = false | 关掉失败标记,先让弹窗消失 |
| 3 | ReplaceCurrentLevel(-1) | 设一个不可能的值(哨兵 sentinel) |
| 4 | ReplaceCurrentLevel(current) | 设回原值(3) |
为什么不能直接 ReplaceCurrentLevel(current)?
因为如果 current 本来就是 3,设 3(值没变),Entitas 的 ReactiveSystem 不会触发。必须先设一个不同的值(-1),再设回去,才能让 LevelStartSystem 检测到变化并重建关卡。
从用户点「开始游戏」到画面第一帧刷新。
两个场景启动器的对比,帮助你理解代码量级跃升的原因。
| 对比维度 | HomeRoot | GameRoot |
|---|---|---|
| 场景 | Home.unity(主菜单) | Main.unity(游戏主界面) |
| 代码行数 | 35 行 | 101 行 |
| 有无 ECS | 无 — 纯 UI,不需要 ECS 世界 | 有 — 整个游戏的逻辑由 ECS 驱动 |
| Update() | 无 — 静态 UI 不需要帧循环 | 有 — 每帧执行所有 System |
| 相机 | 用场景里已有的,只改背景色 | 找不到就自己建:正交投影 + 位置 (0,0,-10) |
| UI 加载方式 | 1 个预制体:Instantiate(prefab) | 4 个预制体 + 1 个代码创建:Instantiate + GetComponent + Bind |
| 预制体引用 | 直接 Load 后用,不存 | 存到 GameEnv,供所有 System 复用 |
| EventSystem | 自己写 EnsureEventSystem() | 一模一样的 EnsureEventSystem()(复制粘贴) |
| 依赖 | Theme.cs(颜色常量) | Contexts、GameFeature、GameEnv、Theme + 十余个 System |
不是「两个文件谁先执行」的问题,而是场景加载先后决定的。Home 场景先加载,Main 场景后加载。
本文件中出现的所有新概念(HomeRoot 中未出现过的)。
| Unity / C# 概念 | 含义 | JS/TS 类比 |
|---|---|---|
Contexts | Entitas 代码生成的 ECS Context 容器 | Vuex Store / Redux Store |
Contexts.sharedInstance | 全局单例 Context | 单例模式 getStore() |
Feature | Entitas System 容器,管控执行顺序 | Redux combineReducers / 中间件链 |
Feature.Initialize() | 初始化所有 System(通常只用来读存档) | Store 初始化 dispatch |
Feature.Execute() | 每帧执行所有 System | requestAnimationFrame 里 dispatch |
Feature.Cleanup() | 清理单帧数据(Input/Destroy) | 批量 clear 临时状态 |
GameEnv | 自定义依赖注入容器 | Provider / Container |
Transform | 位置/旋转/缩放的组件,每个 GameObject 都有 | CSS transform / position |
orthographic | 正交投影(2D 无透视) | perspective: none |
Vector3(x, y, z) | 三维坐标(即使 2D 也用) | { x, y, z } |
tag | GameObject 标签,用于分类查找 | HTML tagName / data-tag |
.transform | 取 GameObject 的 Transform 组件 | element.style / getBoundingClientRect |
GetComponent<T>() | 从 GameObject 获取指定类型的组件 | querySelector('[data-component]') |
Bind(contexts) | 项目自定义方法,注入 ECS 上下文 | provide / inject |
| 哨兵值(Sentinel) | 设一个不可能的值再设回去,触发变化检测 | force update / key change |
_fieldName 下划线 | C# 私有字段命名约定 | #fieldName(JS private) |
ReplaceCurrentLevel(-1) | Entitas 组件替换方法 | state.level = -1 |
左栏:高亮源码 | 右栏:每行的解释、来源、JS 类比。标签:C# 语言特性 / JS 前端类比 / ! 注意事项
sealed 防止被继承(性能优化 + 安全)。继承 MonoBehaviour 才能挂到 GameObject 上。类比:class GameRoot extends ComponentJS_ 前缀 = C# 私有字段约定。C#src: Jenny 生成js: #contexts
Contexts.sharedInstance 获取全局单例。combineReducers。js: const feature = combineReducers({...})
namespace X { } 包裹的类属于「全局命名空间」,同一个程序集(Assembly = 项目编译后的 .dll)里的全局类互相可见。export 的变量可以在同项目任何地方直接用,不需要 import。MonoBehaviour / GameObject 住在 UnityEngine 命名空间里,所以需要 using UnityEngine; 才能找到它们。C#JSnew InputSystem(contexts, _env))。这样 System 不直接调 Camera.main(每次调都会遍历场景),也不每关重新 Resources.Load(很慢),而是预先加载好引用传入。new GameFeature(_contexts, _env) 需要 env 作为参数——所有 System 都要拿到 Camera 和 Prefab 引用。如果先 Reset 再 BuildEnv 不会报错,但 GameFeature 创建时 env 必须已就绪。GameFeature 自己没有定义 Initialize(),它来自顶层基类 Entitas.Systems(Entitas DLL 内)。继承链:GameFeature → Feature(Jenny 生成)→ DebugSystems → Systems(Entitas DLL, Initialize 在这里)Add() 注册的系统,逐个调用它们的 Initialize()。类比:遍历 effects[] 逐个执行 effect.setup()。JSvar = 类型推断,编译时自动推导为 GameObject。<GameObject> = C# 泛型,指定返回值类型。C#<Popup onRetry={retryLevel} />js: 类似 React prop 回调new GameObject 创建空容器。因为作弊面板是纯代码生成的(反射收集所有作弊项)。!
? : 和 JS 完全一样。C#-1。如果直接设 current(值没变),ReactiveSystem 不会触发。!
GameEnv(不是 void)。类比:function buildEnv(): GameEnvC#Camera.main 是静态属性,返回场景中 tag 为 "MainCamera" 的第一个摄像机。js: document.querySelector('[tag="MainCamera"]')== null(不像 JS 用 === null 或 if (!cam))。C#Camera.main 才能找到这个摄像机。类比 HTML 的 data 属性。_env = BuildEnv() 接收。static = 类级别方法,不需要实例就能调用。和 HomeRoot 里完全一样的方法(35行→101行,代码复用但没抽公共类)。C#EventSystem 组件。找不到才创建(幂等操作)。FindObjectOfType 遍历所有 GameObject,性能开销大,但这里只调用一次。!"EventSystem" 只是 Hierarchy 里的显示名,写 "foo" 也行。真正让事件生效的是下面两行 AddComponent。!