Article

Unity 入门实战 01:项目启动与运行时架构(可迁移到 WebGL 的基础盘)

路线切换:C# 基础路线完成后,进入 Unity 工程实践。
本章目标:建立一个长期可维护的 Unity 工程骨架,而不是一次性 Demo 脚本堆叠。

学习目标

完成本章后,你应该能做到:

  1. 按职责拆分 Unity 项目目录,避免后期“脚本垃圾场”。
  2. 建立统一启动入口(Bootstrap)和运行时服务容器。
  3. 设计可测试、可替换、可迁移到 WebGL 的更新循环。
  4. 为后续战斗、UI、资源、输入系统留好扩展点。

为什么第一章不先做玩法

新手常见路径是先把角色动起来,再逐步堆功能。短期很快,但两个月后常见问题:

  1. 场景脚本互相引用,改一个地方会崩一片。
  2. 管理器过多且初始化顺序混乱。
  3. PC 跑得动,WebGL 构建后卡顿和加载问题暴露。
  4. 任何模块都无法独立测试。

这章先把“盘子”搭稳,后续章节迭代成本会明显下降。

目录规范(首版)

建议从一开始就统一:

Assets/
  Game/
    Runtime/
      Bootstrap/
      Core/
      Gameplay/
      UI/
      Services/
    Editor/
    Scenes/
      Boot.unity
      Main.unity
    Prefabs/
    Art/
    Audio/
  Plugins/
  StreamingAssets/

规则:

  1. Runtime 只放运行时代码,禁止编辑器代码混入。
  2. Boot.unity 只做启动和装配,不放玩法对象。
  3. Main.unity 才承载可玩内容。

启动流程设计

统一启动链路:

  1. 启动进入 Boot.unity
  2. GameBootstrap 初始化核心服务
  3. 加载 Main.unity
  4. 进入主循环 GameLoop

GameBootstrap

public sealed class GameBootstrap : MonoBehaviour
{
    private ServiceRegistry _services;
    private GameLoop _loop;

    private void Awake()
    {
        DontDestroyOnLoad(gameObject);

        _services = new ServiceRegistry();
        RegisterCoreServices(_services);

        _loop = new GameLoop(_services);
        _loop.Initialize();

        FoundationLog.Info("Bootstrap", "Core services initialized");
        StartCoroutine(LoadMainScene());
    }

    private IEnumerator LoadMainScene()
    {
        var op = SceneManager.LoadSceneAsync("Main");
        while (!op.isDone)
        {
            yield return null;
        }

        _loop.Start();
        FoundationLog.Info("Bootstrap", "Main scene loaded and loop started");
    }

    private void Update()
    {
        if (_loop != null)
        {
            _loop.Tick(Time.deltaTime, Time.unscaledDeltaTime);
        }
    }

    private void OnDestroy()
    {
        if (_loop != null)
        {
            _loop.Dispose();
            _loop = null;
        }

        if (_services != null)
        {
            _services.Dispose();
            _services = null;
        }
    }

    private static void RegisterCoreServices(ServiceRegistry services)
    {
        services.Register<IGameTime>(new UnityGameTime());
        services.Register<IEventBus>(new UnityEventBusAdapter());
        services.Register<IInputService>(new UnityInputService());
        services.Register<IResourceService>(new UnityResourceService());
    }
}

服务注册与解耦

ServiceRegistry

public sealed class ServiceRegistry : IDisposable
{
    private readonly Dictionary<Type, object> _map = new Dictionary<Type, object>(32);

    public void Register<T>(T instance) where T : class
    {
        var key = typeof(T);
        if (_map.ContainsKey(key))
        {
            throw new InvalidOperationException("Service already registered: " + key.Name);
        }

        _map.Add(key, instance);
    }

    public T Resolve<T>() where T : class
    {
        object value;
        if (!_map.TryGetValue(typeof(T), out value))
        {
            throw new InvalidOperationException("Service not found: " + typeof(T).Name);
        }

        return (T)value;
    }

    public void Dispose()
    {
        foreach (var kv in _map)
        {
            var disposable = kv.Value as IDisposable;
            if (disposable != null)
            {
                disposable.Dispose();
            }
        }

        _map.Clear();
    }
}

关键点:

  1. 明确注册时机:只在 Bootstrap 注册。
  2. 明确生命周期:退出时统一 Dispose
  3. 明确依赖方向:Gameplay 只依赖接口,不依赖具体实现。

运行循环(替代到处写 Update)

public interface IUpdatable
{
    int Order { get; }
    void Tick(float dt, float unscaledDt);
}

public sealed class GameLoop : IDisposable
{
    private readonly ServiceRegistry _services;
    private readonly List<IUpdatable> _updates = new List<IUpdatable>(32);
    private bool _started;

    public GameLoop(ServiceRegistry services)
    {
        _services = services;
    }

    public void Initialize()
    {
        _updates.Clear();

        _updates.Add(new InputSystem(_services));
        _updates.Add(new GameplaySystem(_services));
        _updates.Add(new UISystem(_services));

        _updates.Sort(delegate(IUpdatable a, IUpdatable b)
        {
            return a.Order.CompareTo(b.Order);
        });
    }

    public void Start()
    {
        _started = true;
    }

    public void Tick(float dt, float unscaledDt)
    {
        if (!_started)
        {
            return;
        }

        for (var i = 0; i < _updates.Count; i++)
        {
            _updates[i].Tick(dt, unscaledDt);
        }
    }

    public void Dispose()
    {
        _updates.Clear();
    }
}

好处:

  1. 更新顺序可控。
  2. 单模块可替换可下线。
  3. 后续加“暂停系统/回放系统”只需插入一层。

WebGL 迁移前置约束

从第一章就按 WebGL 要求编码:

  1. 避免线程依赖(WebGL 主线程限制)。
  2. 避免大量反射和动态加载。
  3. 资源加载统一走 IResourceService,为后续 Addressables/WebGL 加载策略留口。
  4. 输入层隔离,未来可接移动端触控而不改 Gameplay。

场景装配实践

Boot.unity 最小对象:

  1. BootstrapRoot(挂 GameBootstrap
  2. UICamera(可选)

Main.unity 最小对象:

  1. WorldRoot
  2. GameplayRoot
  3. UIRoot

原则:场景只放数据与引用关系,业务代码不绑定具体场景层级字符串。

与 C# Foundation 的衔接

前面 C# 路线沉淀的 foundation/Runtime 可直接接入:

  1. 日志:FoundationLog
  2. 断言:FoundationAssert
  3. 事件:EventBus
  4. 调度:Scheduler
  5. 对象池:ObjectPool
  6. 命令与回放:Commands

Unity 层只做适配器,不重写核心逻辑。

验收清单

  1. 项目启动后稳定进入 Main.unity,无初始化顺序异常。
  2. 所有核心系统通过 ServiceRegistry 注入,不出现 FindObjectOfType 依赖。
  3. 主循环内模块执行顺序可配置、可追踪。
  4. 切场景与退出流程中资源可正常释放,无幽灵回调。

常见坑

坑 1:Bootstrap 做成“万能 God Object”

它只负责装配,不应该包含具体玩法逻辑。

坑 2:服务到处注册

注册入口分散会导致生命周期不可控。必须集中在 Bootstrap。

坑 3:Gameplay 直接依赖 Unity 静态 API

会让逻辑难测难迁移。应通过接口适配访问引擎能力。

本月作业

完成一版“可运行空壳工程”:

  1. 有 Boot/Main 双场景与统一启动流程。
  2. 有 3 个可插拔系统(输入、玩法、UI)并按顺序更新。
  3. 接入 FoundationLog,启动到进入主场景全链路有日志。

下一章开始做 Unity 玩法第一步:角色移动与摄像机跟随,并保持可迁移到 WebGL 的输入架构。