INTERNAL / TRAINING Assets/GameBase/App/GameRoot.cs
2026-07-18
Script Analysis

GameRoot.cs

Main 场景的游戏主控制器——装配 ECS 世界、加载所有 UI、控制游戏主循环。 共 101 行,是理解整个项目 ECS 启动链路 最关键的文件。以下从前端工程师视角,逐行拆解。

基类MonoBehaviour
场景Main.unity
核心方法Awake / Update
关联文件GameFeature.cs / GameEnv.cs
CH 01

完整源码

101 行,6 个方法。比 HomeRoot 复杂一个数量级,因为这里要初始化整个 ECS 世界。

GameRoot.cs 101 lines · 6 methods
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>();
        }
    }
}
六件事速览
#方法做什么前端类比
1BuildEnv()创建运行环境:摄像机、关卡容器、箭头/点阵预制体创建 app 容器 + 加载静态资源
2Awake() 前半初始化 ECS 世界:Contexts、GameFeaturenew Vuex.Store() / new Redux store
3Awake() 后半实例化 4 个 UI 预制体 + 作弊面板mount() 渲染子组件
4Update()每帧执行所有 System + 清理requestAnimationFrame 主循环
5RetryLevel()重开当前关卡(哨兵值技巧)重置 state 触发重新渲染
6EnsureEventSystem()同 HomeRoot,确保 UI 可点击同 HomeRoot
CH 02

逐行拆解

从字段声明到 RetryLevel 哨兵技巧,逐段覆盖全部 101 行。

02.1

三个私有字段 — 组装 ECS 的零件

第 8~10 行。

8
Contexts _contexts;

Contexts = Entitas 代码生成器(Jenny)自动生成的类。持有所有 ECS Context 的引用:contexts.gamecontexts.input 等。

下划线前缀 _ = C# 团队编码约定:私有实例字段 用下划线命名。前端类比:#contexts(JS 私有字段)。

JS 类比 // Contexts = 全局 store 引用
class GameRoot {
  #contexts; // private field
  #feature;
  #env;
}
9
GameFeature _feature;

GameFeature = 这个项目自定义的类,继承 Entitas 的 Feature。它把所有 System 按顺序装在一起,提供 Initialize()Execute()Cleanup() 三个统一入口。

类比:Feature = Redux 的 combineReducers + Vuex 的 modules,把分散的逻辑聚合到一条执行流水线。

10
GameEnv _env;

GameEnv = 这个项目自定义的 依赖注入容器(DI Container)。只存 4 样东西:
CameraRoot(关卡物体父节点)、ArrowPrefabGridPrefab

JS 类比 const env = {
  camera: mainCamera,
  root: levelContainer,
  arrowPrefab: { /* 箭头模板 */ },
  gridPrefab: { /* 点阵模板 */ }
};
// Provider 传给所有 System
02.2

BuildEnv() — 搭建运行环境

第 65~90 行。创建摄像机、关卡容器、加载预制体引用。

65~67
GameEnv BuildEnv()
{
var env = new GameEnv();

返回类型是 GameEnv,不是 void。创建一个空的 GameEnv 对象,然后逐步填充。

69~74
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 组件,然后它会返回这个组件的引用

76
cam.orthographic = true;

orthographic = 正交投影(无透视)。2D 游戏标配。
设为 false 的话就是透视投影(3D 游戏用的),物体会近大远小。

JS 类比 CSS 的 perspective: none; // 2D 无透视
vs perspective: 800px; // 3D 有透视
77~79
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = Theme.MainBackgroundColor;
cam.transform.position = new Vector3(0, 0, -10);

设置纯色白底 + 摄像机位置。注意:Z = -10,摄像机放在画面后面 10 单位远。
2D 正交投影下 Z 值决定渲染层级,只要在物体后面就行。前端类比:z-indextransform: translateZ(-10px)

80
env.Camera = cam;

把摄像机存到 GameEnv 里。之后任何 System 需要摄像机时通过 _env.Camera 就能拿到。

82
env.Root = new GameObject("LevelRoot").transform;

创建空 GameObject,名字 "LevelRoot",取其 Transform 组件存入 env。关卡加载后所有箭头、点阵都挂在这个空节点下,方便组织 Hierarchy。

JS 类比 const root = document.createElement('div');
root.id = 'level-root';
// 关卡里的箭头都是 root.appendChild(arrow)
84~87
env.ArrowPrefab = Resources.Load<GameObject>("Prefabs/Arrow");
env.GridPrefab = Resources.Load<GameObject>("Prefabs/Grid");

预加载箭头和点阵的预制体引用。存到 env 里,当 LevelStartSystem 加载关卡时直接用,不需要每关都 Resources.Load(很慢)。

89
return env;

返回组装好的 GameEnv。回到 Awake 里 _env = BuildEnv() 接收。

02.3

ECS 世界初始化 — 整个项目的心脏

第 16~21 行,Awake() 中最关键的 5 行。

16
_contexts = Contexts.sharedInstance;

Contexts.sharedInstance = 全局唯一的 ECS Context 单例。

_contexts.game = 游戏数据的 Context(关卡、命值、奖励等都在这)
_contexts.input = 输入数据的 Context(鼠标点击、拖拽等)
项目目前只用了这两个 Context。

JS 类比 const contexts = {
  game: new Context(), // 游戏状态 store
  input: new Context() // 输入状态 store
};
18
_contexts.Reset();

每次进 Main 场景都重置。因为 Contexts 是单例,用户可能需要「返回主菜单 → 再点开始游戏」,如果不 Reset,上次关卡的数据会残留。

20
_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 按顺序串成流水线

GameFeature 内部 System 流水线简图
1
SavegameFeature — 读档,设置 CurrentLevel/Awards
2
InputSystem / TimerSystem — 检测鼠标输入和计时
3
ArrowClick / Block / Move / Complete — 核心玩法逻辑
4
LivesMistake / LevelFail — 命值扣除和失败判定
5
LevelComplete / Reload / Start — 关卡流转
6
AwardsFeature — 奖励计算
7
DestroySystem — 清理标记为 Destroy 的 Entity

Add() 的顺序 = 执行顺序。存档最前(先恢复数据),销毁最后(收尾清理)。

21
_feature.Initialize();

调用所有 System 的 Initialize()。大部分 System 的 Initialize 是空的,但 SavegameSystem 会在这里 读 JSON 文件,恢复存档数据到 ECS 组件

02.4

UI 预制体实例化 — Instantiate + GetComponent + Bind

第 26~47 行。这个项目里 UI 加载的标准三板斧。

26
var hudPrefab = Resources.Load<GameObject>("Prefabs/GameHud");

从 Resources/Prefabs/GameHud.prefab 加载 HUD 预制体。

27~28
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 脚本引用。

JS 类比 const instance = prefab.cloneNode(true);
const hudScript = instance.getComponent(GameHud);
hudScript.bind(contexts); // 注入 ECS 上下文
29~30
else
Debug.LogError("HUD 预制体缺失...");

如果 HUD 没加载到(Build UI Prefabs 没执行过),打错误日志。

33~35
var toastPrefab = Resources.Load<GameObject>("Prefabs/AwardToast");
if (toastPrefab != null)
Instantiate(toastPrefab).GetComponent<AwardToast>().Bind(_contexts);

AwardToast(奖励弹出提示),同样的三板斧模式。注意这里没有 else 错误日志——奖励提示是可选的 UI,没加载也不会影响游戏运行,所以不打日志。

38~42
var failPrefab = Resources.Load<GameObject>("Prefabs/LevelFailPopup");
if (failPrefab != null)
Instantiate(failPrefab).GetComponent<LevelFailPopup>().Bind(_contexts, RetryLevel);

失败弹窗有点不同:Bind 传了两个参数 —— _contextsRetryLevel。第二个参数是回调函数,当用户点「重试」按钮时调用。

JS 类比 // Vue: <LevelFailPopup :contexts="contexts" @retry="retryLevel" />
// React: <LevelFailPopup contexts={contexts} onRetry={retryLevel} />
44~46
var cheatGo = new GameObject("CheatPanel");
cheatGo.AddComponent<CheatPanel>();

作弊面板不走预制体,直接在代码里创建空 GameObject + AddComponent。因为它是纯代码生成的(用反射收集所有作弊项加到按钮上),不需要可视化编辑。

JS 类比 import { CheatPanel } from './cheat-panel';
// 直接 new,不走模板
const cheat = new CheatPanel();
document.body.appendChild(cheat.render());
02.5

Update() — 游戏主循环,只有两行

第 49~53 行。

49~53
void Update()
{
_feature.Execute();
_feature.Cleanup();
}

整个游戏的「心脏跳动」就在这两行。每帧都会执行:

调用做什么前端类比
Execute()跑一遍所有 System。按 GameFeature 里 Add 的顺序依次调用每个 System 的 Execute()Redux dispatch 链 / Vuex action 链
Cleanup()清理单帧数据。输入事件只存在一帧,下一帧就清掉。标记 Destroy 的 Entity 也在这里删掉批量 clear 临时状态 / GC
一帧内发生了什么?
1
InputSystem 检测鼠标/触摸 → 写入 ECS
2
ArrowClickSystem 读到点击事件 → 判断拔/撞 → 改箭头状态
3
LivesMistakeSystem 读到撞了 → 扣命 → 判断 0 命 → 触发 LevelFailed
4
ArrowsCompleteSystem 读到全拔完 → 触发通关
5
UI Update()(GameHud 等)读到状态变化 → 刷新显示
6
Cleanup() — 清 Input Entity、删 Destroy Entity
02.6

RetryLevel() — 哨兵值重试技巧

第 56~63 行。精巧的「通过设 -1 再设回来触发 System」的模式。

56~63
void RetryLevel()
{
var current = _contexts.game.hasCurrentLevel
? _contexts.game.currentLevel.Value : 1;
_contexts.game.isLevelFailed = false;
_contexts.game.ReplaceCurrentLevel(-1);
_contexts.game.ReplaceCurrentLevel(current);
}

这段代码看着绕,但思路很聪明:

步骤代码为什么
1var current = ...记下当前第几关(比如第 3 关)
2isLevelFailed = false关掉失败标记,先让弹窗消失
3ReplaceCurrentLevel(-1)设一个不可能的值(哨兵 sentinel)
4ReplaceCurrentLevel(current)设回原值(3)

为什么不能直接 ReplaceCurrentLevel(current)?
因为如果 current 本来就是 3,设 3(值没变),Entitas 的 ReactiveSystem 不会触发。必须先设一个不同的值(-1),再设回去,才能让 LevelStartSystem 检测到变化并重建关卡。

JS 类比 // Vue 中的类似手法:
this.level = -1; // 先破坏
await nextTick();
this.level = 3; // 再重建,触发 watcher
CH 03

完整启动链路

从用户点「开始游戏」到画面第一帧刷新。

Main.unity 加载 → 第一帧 Update 完整链路
1
Home 场景点「开始游戏」→ UnityEngine.SceneManagement.LoadScene("Main") → Main.unity 加载
2
GameRoot.Awake() 被 Unity 调用
2a
BuildEnv() — 创建 GameEnv
  • 找到或创建 MainCamera → 正交投影 + 白底 + 位置 (0, 0, -10)
  • 创建 LevelRoot 空节点
  • 预加载 Arrow.prefab 和 Grid.prefab
2b
ECS 初始化
  • Contexts.sharedInstance → 拿全局 Context
  • _contexts.Reset() → 清空上次残留
  • new GameFeature(_contexts, _env) → 组装所有 System
  • _feature.Initialize() → 存档系统读 JSON、恢复数据
2c
EnsureEventSystem() — 确保 UGUI 事件系统存在
2d
加载 UI 预制体
  • GameHud → Instantiate + GetComponent + Bind(_contexts)
  • AwardToast → 同上
  • LevelFailPopup → 同上 + Bind(_contexts, RetryLevel)
  • CheatPanel → new GameObject + AddComponent
3
Unity 进入主循环,开始每帧调用 GameRoot.Update()
3a
第一帧(保存有进度的前提下):
  • SavegameSystem 已在 Initialize 恢复了 CurrentLevel
  • LevelStartSystem 检测到 CurrentLevel 有值 → 加载关卡数据 → 创建箭头和点阵
4
以后每帧重复: Execute → Cleanup → 等待下一帧
TypeScript 伪代码(极简版)
class GameRoot extends MonoBehaviour { #contexts: Contexts; #feature: GameFeature; #env: GameEnv; awake(): void { this.#env = this.buildEnv(); // ① 创建运行环境 this.#contexts = Contexts.sharedInstance; // ② 拿 ECS 单例 this.#contexts.reset(); // 清空残留 this.#feature = new GameFeature(this.#contexts, this.#env); this.#feature.initialize(); // ③ 读档恢复数据 this.ensureEventSystem(); // ④ 确保 UI 可点击 const hud = Resources.load('Prefabs/GameHud'); // ⑤ 加载 UI instantiate(hud).getComponent(GameHud).bind(this.#contexts); // ... 其他 UI 同理 } update(): void { this.#feature.execute(); // 每帧:跑所有 System this.#feature.cleanup(); // 清理单帧数据 } retryLevel(): void { const current = this.#contexts.game.currentLevel?.Value ?? 1; this.#contexts.game.isLevelFailed = false; this.#contexts.game.replaceCurrentLevel(-1); // 哨兵 this.#contexts.game.replaceCurrentLevel(current); // 触发 System } }
CH 04

GameRoot vs HomeRoot

两个场景启动器的对比,帮助你理解代码量级跃升的原因。

对比维度HomeRootGameRoot
场景 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
执行顺序总结
1
Home.unity 加载HomeRoot.Awake() 执行(主菜单显示)
2
用户点"开始游戏" → Main.unity 加载
3
GameRoot.Awake() 执行(ECS 初始化 + UI 挂载)
4
每帧 GameRoot.Update() → _feature.Execute() → _feature.Cleanup()

不是「两个文件谁先执行」的问题,而是场景加载先后决定的。Home 场景先加载,Main 场景后加载。

CH 05

知识点速查表

本文件中出现的所有新概念(HomeRoot 中未出现过的)。

Unity / C# 概念含义JS/TS 类比
ContextsEntitas 代码生成的 ECS Context 容器Vuex Store / Redux Store
Contexts.sharedInstance全局单例 Context单例模式 getStore()
FeatureEntitas System 容器,管控执行顺序Redux combineReducers / 中间件链
Feature.Initialize()初始化所有 System(通常只用来读存档)Store 初始化 dispatch
Feature.Execute()每帧执行所有 SystemrequestAnimationFrame 里 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 }
tagGameObject 标签,用于分类查找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
CH 06

双栏对照 · 源码 + 逐行注释

左栏:高亮源码 | 右栏:每行的解释、来源、JS 类比。标签:C# 语言特性 / JS 前端类比 / ! 注意事项

GameRoot.cs (Assets/GameBase/App/)
解释 / 来源 / 类比
1using UnityEngine;
导入 Unity 引擎核心 API(GameObject、MonoBehaviour、Debug 等)。C#
2using UnityEngine.EventSystems;
导入 UGUI 事件系统(EventSystem、StandaloneInputModule)。C#
4// 训练工程游戏根节点(放在 Main 场景里)。
XML 文档注释风格。说明这个脚本挂载在 Main 场景!
5// 负责装配 ECS、加载关卡、创建 HUD。
一句话概括职责:ECS 初始化 + 关卡 + UI。
6public sealed class GameRoot : MonoBehaviour
sealed 防止被继承(性能优化 + 安全)。继承 MonoBehaviour 才能挂到 GameObject 上。类比:class GameRoot extends ComponentJS
8 Contexts _contexts;
ECS Context 容器。Jenny 代码生成器自动生成的类,持有所有 Context 引用。_ 前缀 = C# 私有字段约定。C#src: Jenny 生成js: #contexts
Contexts 是什么?怎么生成的?
Contexts 是由 Jenny 代码生成器根据 Entitas 组件定义自动生成的类。你写完组件(如 CurrentLevelComponent)后运行 Jenny,它会把所有 [Game]、[Input] 等 Context 聚合到一个 Contexts 类里。之后用 Contexts.sharedInstance 获取全局单例。
9 GameFeature _feature;
System 流水线容器。把所有 System 按 Add 顺序串起来。类比 Redux 的 combineReducersjs: const feature = combineReducers({...})
为什么需要 Feature 而不是直接调 System?
Entitas 的 Feature 类似中间件管道。它保证 System 按你注册的顺序串行执行。比如必须先执行 Input 再执行 Arrow(因为点击事件需要先处理),一个 Feature 管好执行顺序。类比 Express/Koa 的中间件链。
GameFeature / GameEnv 为什么不需要 using?
因为没写 namespace。C# 里,没被 namespace X { } 包裹的类属于「全局命名空间」,同一个程序集(Assembly = 项目编译后的 .dll)里的全局类互相可见。

类比:JS 里没有 export 的变量可以在同项目任何地方直接用,不需要 import

反例:MonoBehaviour / GameObject 住在 UnityEngine 命名空间里,所以需要 using UnityEngine; 才能找到它们。C#JS
10 GameEnv _env;
运行时依赖注入容器。存 Camera、Root、ArrowPrefab、GridPrefab,供所有 System 使用。js: const env = { camera, root, arrowPrefab }
为什么把 Camera 和 Prefab 放 GameEnv 里?
依赖注入。所有 System 构造函数都接收 GameEnv 参数(见 GameFeature.cs 里 new InputSystem(contexts, _env))。这样 System 不直接调 Camera.main(每次调都会遍历场景),也不每关重新 Resources.Load(很慢),而是预先加载好引用传入。
12 void Awake()
Unity 生命周期第一个回调。场景加载后、Start() 之前执行。只执行一次。C#js: 类似 Vue mounted() / React useEffect([], [])
14 _env = BuildEnv();
先组装运行环境(→ 跳转到 L65)。! 必须在 ECS 初始化之前执行。
为什么 BuildEnv 必须在 _contexts.Reset 之前?
之后的 new GameFeature(_contexts, _env) 需要 env 作为参数——所有 System 都要拿到 Camera 和 Prefab 引用。如果先 Reset 再 BuildEnv 不会报错,但 GameFeature 创建时 env 必须已就绪。
16 _contexts = Contexts.sharedInstance;
拿 ECS 全局单例。整个游戏只有一个 Contexts。src: Jenny 生成js: const contexts = Contexts.sharedInstance
为什么用 sharedInstance 而不是 new Contexts()?
Entitas 的 Contexts 是单例模式。多处代码(GameRoot、CheatPanel、各种 System)都要访问同一个 ECS 世界。如果各自 new 一个,数据不互通——A System 改了 CurrentLevel,B System 看不到。
17 // 每次进入 Main 场景重置一份干净的 ECS 世界
为什么需要 Reset?因为用户可能「返回主菜单→再开始」——不重置会残留上次关卡数据。!
18 _contexts.Reset();
清空所有 Entity 和 Component,回到干净状态。js: store.reset()
20 _feature = new GameFeature(_contexts, _env);
组装 System 流水线。构造函数里按序 Add 所有 System。src: GameFeature.cs
21 _feature.Initialize();
触发所有 System 初始化。SavegameSystem 在这里读 JSON 存档文件恢复数据。!
Initialize 做了什么?
构造函数只注册 System(Add),Initialize 才调用每个 System 的 Initialize 方法。分开的好处:所有 System 都完成注册后一起初始化,保证顺序可预期。存档 System 的 Initialize 从 JSON 加载数据,必须在其他 System 之前完成(不然命值为 0)。
这个方法从哪来的?GameFeature 自己没写 Initialize
来自继承链。GameFeature 自己没有定义 Initialize(),它来自顶层基类 Entitas.Systems(Entitas DLL 内)。继承链:

GameFeatureFeature(Jenny 生成)→ DebugSystemsSystems(Entitas DLL, Initialize 在这里)

Systems.Initialize() 的逻辑很简单:遍历所有通过 Add() 注册的系统,逐个调用它们的 Initialize()。类比:遍历 effects[] 逐个执行 effect.setup()JS
23 EnsureEventSystem();
和 HomeRoot 里完全一样的方法(L92-100)。确保 UGUI 事件系统存在,否则按钮不响应。!
25 // HUD(命值 + 关卡号 + 返回):实例化预制体
HUD = Heads-Up Display,游戏内常驻信息栏。
26 var hudPrefab = Resources.Load<GameObject>("Prefabs/GameHud");
从 Resources/Prefabs/ 加载预制体。var = 类型推断,编译时自动推导为 GameObject<GameObject> = C# 泛型,指定返回值类型。C#
27if (hudPrefab != null)
防御式判空:预制体没加载到就跳过,不打日志(HUD 是可选的?不对,这里有 else 错误日志)。
28 Instantiate(hudPrefab).GetComponent<GameHud>().Bind(_contexts);
UI 三板斧:①克隆预制体 ②拿脚本引用 ③注入 ECS 上下文。这是本项目 UI 初始化的核心模式
js: const hud = mount(prefab); hud.bind(contexts);
为什么 UI 初始化是这三步而不是拖到场景?
代码动态创建的 UI 更灵活。拖到场景 = 设计师在编辑器里手动放,适合静态 UI。代码 Instantiate = 运行时创建,可以传参数(如 Bind 注入 contexts)、控制时机(等 ECS 初始化完再创建 UI)。这个项目的 UI 都依赖 ECS 数据,必须在 _feature.Initialize() 之后创建。
30 Debug.LogError("HUD 预制体缺失…");
预制体缺失时打印红色错误。说明 HUD 不可缺失!
33 var toastPrefab = Resources.Load<GameObject>("Prefabs/AwardToast");
奖励弹出提示预制体。注意这里没有 else 错误日志——说明 AwardToast 是可选的UI。!
35 Instantiate(toastPrefab).GetComponent<AwardToast>().Bind(_contexts);
同一个三板斧模式。js: mount(prefab).bind(contexts)
38 var failPrefab = Resources.Load<GameObject>("Prefabs/LevelFailPopup");
失败弹窗预制体。命值归零后弹出。
40 Instantiate(failPrefab).GetComponent<LevelFailPopup>().Bind(_contexts, RetryLevel);
Bind 传了两个参数:_contexts + 回调函数 RetryLevel。用户点「重试」时调用 RetryLevel()。类比:<Popup onRetry={retryLevel} />js: 类似 React prop 回调
45 var cheatGo = new GameObject("CheatPanel");
不走预制体,直接 new GameObject 创建空容器。因为作弊面板是纯代码生成的(反射收集所有作弊项)。!
为什么 CheatPanel 不用预制体?
CheatPanel 的 UI 是运行时通过 C# 反射动态生成的——它扫描所有单例组件,给每个组件生成一个输入框/按钮。这种完全由代码生成的 UI 不需要可视化编辑,所以直接从代码 new GameObject + AddComponent 就行。
46 cheatGo.AddComponent<CheatPanel>();
挂上 CheatPanel 脚本。CheatPanel 自己会通过反射生成按钮。不需要调用 Bind(),因为它直接拿 Contexts.sharedInstance。!
47 }
Awake() 结束。ECS 世界已初始化,所有 UI 已挂载,进入 Update 循环。
49 void Update()
Unity 每帧调用。整个游戏的「心跳」。C#js: requestAnimationFrame(update)
Update 每帧都跑,不卡吗?
不会,因为 System 是选择性执行的。ReactiveSystem 只在数据变化时跑,IExecuteSystem 才每帧跑。这个项目大部分是 ReactiveSystem(事件驱动),只有 InputSystem 等少数几个是每帧轮询。Update 的开销主要在 _feature.Execute() 里分发。
51 _feature.Execute();
按 GameFeature 里 Add 的顺序,依次执行所有 Systemjs: dispatch 链式调用
52 _feature.Cleanup();
清理单帧数据:删除 Input Entity、Destroy Entity。js: clearTempState()
为什么需要 Cleanup?不清理会怎样?
输入事件(如点击)是单帧 Entity——这一帧创建它,System 处理后,下一帧就没了。如果不 Cleanup,Input Entity 会一直积累导致内存泄漏。Cleanup 删除所有标记为 Destroy 的 Entity,保持 ECS 世界干净。
56 void RetryLevel()
关卡重试回调。由 LevelFailPopup 的「重试」按钮触发。
58 varcurrent = _contexts.game.hasCurrentLevel ? _contexts.game.currentLevel.Value : 1;
三元表达式:如果有 CurrentLevel 组件就用它的值,否则默认第 1 关。C# 的 ? : 和 JS 完全一样。C#
59 _contexts.game.isLevelFailed = false;
关闭失败标记(实体组件赋值)。失败弹窗收到这个变化后自动隐藏。js: state.isLevelFailed = false
61 _contexts.game.ReplaceCurrentLevel(-1);
哨兵值技巧:设一个不可能的关卡号 -1。如果直接设 current(值没变),ReactiveSystem 不会触发。!
为什么不能直接 _contexts.game.ReplaceCurrentLevel(current)?
ReactiveSystem 的触发条件是值变了。如果当前已经是第 3 关,再 ReplaceCurrentLevel(3) 值没变,LevelStartSystem 不会被触发,关卡不会重建。所以先用 -1 这个不可能的哨兵值「骗」一下,再设回 3,触发变化检测。
js: 类似 this.$forceUpdate()
62 _contexts.game.ReplaceCurrentLevel(current);
设回原值(如 3)。LevelStartSystem 检测到 CurrentLevel 从 -1→3,触发关卡重建。js: 类似 this.$forceUpdate()
65 GameEnv BuildEnv()
返回类型是 GameEnv(不是 void)。类比:function buildEnv(): GameEnvC#
67 var env = new GameEnv();
创建空容器,之后逐步填充字段。
69 var cam = Camera.main;
Camera.main 是静态属性,返回场景中 tag 为 "MainCamera" 的第一个摄像机。js: document.querySelector('[tag="MainCamera"]')
70 if (cam == null)
C# 判空用 == null(不像 JS 用 === nullif (!cam))。C#
72 var camGo = new GameObject("Main Camera");
找不到就自己建一个。和 HomeRoot 不同——HomeRoot 假设场景里已经有摄像机,GameRoot 更健壮。!
73 camGo.tag = "MainCamera";
设置 tag。这样以后 Camera.main 才能找到这个摄像机。类比 HTML 的 data 属性。
74 cam = camGo.AddComponent<Camera>();
给空 GameObject 挂 Camera 组件,返回值就是这个 Camera 组件的引用。一箭双雕:挂组件 + 拿引用。
76 cam.orthographic = true;
正交投影(无透视)。2D 游戏必须开。js: CSS perspective: none
正交 vs 透视投影?
透视投影(默认):远小近大,3D 游戏用。正交投影:一样大,2D 游戏用。切换的标志就是 cam.orthographic = true。不设这个 Arrows 游戏的箭头看起来会变形扭曲。
79 cam.transform.position = new Vector3(0, 0, -10);
摄像机位置:Z = -10,放在画面后面。2D 正交投影下 Z 只决定渲染层级。js: 类似 z-index
82 env.Root = new GameObject("LevelRoot").transform;
创建空容器取 Transform 组件(位置/旋转/缩放)。关卡箭头都挂在这个节点下。js: document.createElement('div').id='level-root'
84 env.ArrowPrefab = Resources.Load<GameObject>("Prefabs/Arrow");
预加载箭头模板引用。System 用到时直接 Instantiate,不需要每关都 Load(很慢)。!
89 return env;
返回组装好的 GameEnv。回到 L14:_env = BuildEnv() 接收。
92 static void EnsureEventSystem()
static = 类级别方法,不需要实例就能调用。和 HomeRoot 里完全一样的方法(35行→101行,代码复用但没抽公共类)。C#
94 if (Object.FindObjectOfType<EventSystem>() == null)
全局搜索 EventSystem 组件。找不到才创建(幂等操作)。FindObjectOfType 遍历所有 GameObject,性能开销大,但这里只调用一次。!
96 var es = new GameObject("EventSystem");
"EventSystem" 只是 Hierarchy 里的显示名,写 "foo" 也行。真正让事件生效的是下面两行 AddComponent。!
97 es.AddComponent<EventSystem>();
挂上 Unity 内置的 EventSystem 类(无引号 = 类型),负责事件分发。C#
98 es.AddComponent<StandaloneInputModule>();
挂上输入驱动,处理鼠标/触摸/键盘输入。这两个组件一起 = UGUI 事件系统。!
100 }
EnsureEventSystem 结束。
101}
GameRoot 类结束。全部 101 行。5 个方法:Awake、Update、RetryLevel、BuildEnv、EnsureEventSystem。