5. 核心概念

每个 CGS 服务背后的五个理念——引导、服务定位器、时钟、事件与空实现——外加术语表。

五个理念就能解释整个框架。掌握它们之后,23 个服务里的每一个都以同样的方式工作。本章用平实的语言逐一讲解,并配上可以直接粘进你项目的简短代码。章末的术语表定义了本手册用到的所有技术词汇。

5.1 引导:一切自行启动

Common Game System 没有需要拖进场景的预制体,没有管理器对象,也没有初始化调用。当你按下 Play 时,Unity 会在你的第一个场景加载之前自动运行框架的启动代码。这个启动步骤叫做引导(bootstrap)。它按正确顺序创建全部 23 个服务,并逐一注册,让你的代码可以找到它们。

按下 Play 后看看 Console 就能确认它是否成功。最后一行启动消息是:

bootstrap complete (v2.1.0, 23 services)

你永远不需要自己调用引导。它每个 Play 会话恰好运行一次,第二次调用会故意抛出错误。等到你场景中的任何 Awake() 运行时,所有服务都已就绪待命。完整参考:Bootstrap

5.2 服务定位器:问一次,记住答案

服务是一个自成一体的功能——存档、音频、定时器、输入——通过单个 C# 接口访问。服务定位器是收录所有服务的电话簿。你按接口向它索取服务,它把正在运行的实例交给你:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // run after the framework has started
public class GameSetup : MonoBehaviour
{
    private ITimeService _time;
    private ISaveService _save;

    private void Awake()
    {
        // Ask once, keep the answer.
        _time = ServiceLocator.Resolve<ITimeService>();
        _save = ServiceLocator.Resolve<ISaveService>();
    }
}

两个习惯让这既快又安全:

  • 只在 Awake()Start() 中解析一次,并把结果存进字段。 每次查找都是一次字典检索。每帧调用它只是白白浪费时间。
  • 给在 Awake() 中解析服务的类加上 [DefaultExecutionOrder(100)] 它让 Unity 在框架自身的对象之后运行你的脚本,确保所有服务都已就绪。

如果某个服务可能不存在——例如你移除了某个可选 Unity 包——改用 ServiceLocator.TryResolve<T>(out var service)。它会返回 false 而不是抛异常。完整参考:Service Locator

列出每个已注册服务的 Service Debugger 窗口

Service Debugger 窗口(Tools > Common Game System > Service Debugger)在你运行游戏时列出每个已注册服务。如果你期望的服务没有出现在这里,多半是它对应的可选 Unity 包没有安装——见第 7.2 章。显示的数量可能大于 23——框架在 23 个公开服务之外还会注册几个内部辅助服务。

5.3 时钟:三种速度的时间

时钟是一条独立的时间流。框架运行着三条:Clock.GameplayClock.UIClock.Background。每一条都可以单独暂停或减速,而不影响另外两条。这解决了一个经典 bug:你暂停了游戏,结果菜单动画也跟着冻住,音乐也停了。

  • Gameplay —— 你的游戏世界:角色、物理反应、冷却时间。暂停菜单停的就是这条时钟。
  • UI —— 菜单和覆盖层。游戏玩法暂停时它继续运行,按钮因此仍有动画。
  • Background —— 音乐以及任何永远不该停下的东西。

一个暂停菜单只需两行:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class PauseMenu : MonoBehaviour
{
    private ITimeService _time;

    private void Awake() => _time = ServiceLocator.Resolve<ITimeService>();

    public void Open()  => _time.Pause(Clock.Gameplay);   // world freezes
    public void Close() => _time.Resume(Clock.Gameplay);  // world resumes
}

_time.DeltaTime(Clock.Gameplay) 而不是 Unity 的 Time.deltaTime 来按时钟读取时间。慢动作只需一次调用:_time.SetTimeScale(Clock.Gameplay, 0.3f) —— UI 和音乐保持正常速度。调度器的定时器和补间服务的动画各自绑定到某条时钟,因此无需额外代码就能正确地暂停和减速。唯一的规则:不要自己设置 Unity 的全局 Time.timeScale,那会与按时钟计时的系统相互打架。完整参考:Time

5.4 事件:订阅、保管句柄、释放它

事件总线让各个系统在互不知晓的情况下交流。一个脚本发布一个事件对象;该事件类型的所有订阅者会按订阅顺序被立即调用。订阅会返回一个小小的句柄。释放(Dispose)这个句柄即取消订阅。忘记释放会让你的处理器永远保持注册——那就是内存泄漏。

using System;
using CommonGameSystem.Core;
using UnityEngine;

// Events are plain classes you define yourself.
public class ScoreChanged
{
    public int NewScore;
}

[DefaultExecutionOrder(100)]
public class ScoreLabel : MonoBehaviour
{
    private IEventBus _events;
    private IDisposable _subscription;

    private void Awake()   => _events = ServiceLocator.Resolve<IEventBus>();
    private void OnEnable() => _subscription = _events.Subscribe<ScoreChanged>(OnScore);
    private void OnDisable() => _subscription?.Dispose(); // always unsubscribe

    private void OnScore(ScoreChanged e) => Debug.Log($"Score: {e.NewScore}");
}

在任何地方发布都只是一行:_events.Publish(new ScoreChanged { NewScore = 100 });。有两个细节值得了解:事件类型必须是类(不能是结构体);某个订阅者抛出异常绝不会阻塞其他订阅者——错误会被记录,投递继续进行。完整参考:Event Bus

5.5 空实现:每个服务都有开关

每个服务都附带一个配套的空实现(Null implementation)——接受所有调用但什么都不做的版本。注册它就能关掉对应的子系统,而不必改动任何一个调用方。让全项目所有音频静音只需一行:

ServiceLocator.Replace<IAudioService>(new NullAudio());

你项目里的每个 PlayMusicPlaySfx 调用仍然能编译、仍然能运行——只是什么都不做。这对测试、无头服务器、录音会话,或用你自己的方案替换某个子系统都很有用。第 7.4 章列出了全部 23 个服务的开关,以及在会话中途替换某些服务时应额外加上的一行清理代码。

5.6 术语表

术语含义
服务(Service)一个自成一体的框架功能(存档、音频、定时器),通过单个 C# 接口使用。
解析(Resolve)按接口向服务定位器索取服务。只做一次,并把引用保存在字段里。
引导(Bootstrap)在你的第一个场景加载之前创建并注册全部 23 个服务的自动启动步骤。你永远不需要调用它。
时钟(Clock)三条独立时间流(Gameplay、UI、Background)之一。每条都可以单独暂停或减速。
补间(Tween)一段短动画,在设定的时长内把一个值从起点平滑地移动到终点,形状由缓动曲线决定。
动作映射(Action map)Unity Input System 资产内一组命名的输入动作(如 “Gameplay” 或 “Menu”)。只有已启用的映射才会响应玩家。
AddressablesUnity 的可选包,用于通过文本地址而非直接引用来按需加载资产。
IL2CPPUnity 把 C# 转换为原生代码的构建模式。它会移除看起来未被使用的类,这正是你的存档类需要 link.xml 条目的原因(第 6.3 章)。

下一章:6. 设置步骤图解