第一个能跑的场景

1.1 先说清楚什么叫"跑起来"

"跑起来"这个说法很虚。我给自己定了三条验收线,达不到就不叫跑起来:

  1. 预览窗口里能看到东西在动,不是看到一张静止的图。
  2. 这次动起来,是我改一行代码的结果,重启预览后依然在动。
  3. 关掉编辑器,重新打开项目,预览结果不变。

第三条是最容易被忽略的。很多新手会经历这样一个循环:改代码,预览,动;再改,再预览,动。感觉一切正常。然后某天重开编辑器,发现东西不动了,或者动的方式变了。原因是那些"生效"的东西其实是在编辑器内存里攒出来的,没有真正写进资源文件。

我在这个项目里做的第一个东西非常小:一个方块,在屏幕上按方向键能移动,撞到墙壁会停。我没有先写无人机,没有先写订单列表,也没有先做美术。那些东西都得等"最小可跑版本"跑通之后才加。

顺序是这样的:建空场景 → 挂一个相机和 Canvas → 写一个脚本 → 挂到节点上 → 预览看到方块移动 → 换成无人机预制体 → 加第二个脚本做物理碰撞。

1.2 五个步骤,每一步的具体动作

我把这套流程拆开写,因为新手卡住的地方基本都是某一步没做对。

第一步,建场景。Dashboard 里新建项目,模板选 Empty(2D),不要选 3D 模板,多余的相机和灯光后面要一个个删。打开编辑器,在层级管理器右键 Create → Scene,命名 Main。双击场景打开它。

第二步,搭骨架。这个场景最后长这样:

  • Canvas(自动带)
  • Camera(自动带)
  • UI Root
    • HUD 放订单列表和分数
    • World 放游戏场景内容
    • LevelRoot 放关卡
    • Player 放无人机

层级命名我一开始就定好,因为后面要按路径找节点,节点名写死了代码才稳。

第三步,写第一个脚本。放在 assets/scripts/gameplay/ 下面,文件名 PlayerController.ts。3.8 里脚本是 TypeScript,用 class 继承 Component,在 start 里改自己的 node 属性。

第四步,挂组件。回到编辑器,选中 Player 节点,属性检查器底部点"添加组件",搜 PlayerController,加上去。挂上之后,节点名字旁边会多一个脚本图标,3.8.7 之后还有个 TS 标记,看到标记就知道脚本挂上了。

第五步,预览。点编辑器顶部那个三角形按钮。第一次预览会慢,编辑器要编译脚本。等窗口出来,方块应该已经在动了。

这五步做完,一个最小可跑版本就成立了。之后所有功能都是在这个骨架上加东西。

1.3 新手最容易卡住的五个点

这五个我全踩过,每一个都花了不少时间。后面我给团队新人做培训,这页是必讲的。

卡住的点 你看到的现象 真正的原因 怎么解决
脚本没挂到节点上 预览里方块一动不动,控制台连报错都没有 脚本文件存在,但组件没添加到节点上。代码不执行,也就没有任何输出 选中节点,属性检查器底部"添加组件"里搜脚本类名,加上去。或者在代码里用 getComponent 拿到它再调方法
组件属性没拖进去 报错 Cannot read property 'xxx' of undefined,或者数值全是 0 脚本里用 @property 声明的属性,在编辑器里还没赋值,运行时是 undefined 或默认值 选中节点,在属性检查器里找到那个属性,把节点或组件拖进去。拖不进去通常是因为类型不匹配,比如脚本要的是 RigidBody2D 你拖了个普通节点
坐标系用错 物体往反方向跑,或者斜着跑,或者位置一直不对 3.x 里 node.position 是相对父节点的本地坐标,angle 是弧度不是角度 明确区分本地和世界:node.position 配 node.setParent 用,node.worldPosition 拿全局位置。角度用 MathUtils.toRadian 转
事件没绑上 按钮点了没反应,或者 on 绑的回调触发两次 编辑器里绑的事件和代码里 on 绑的事件叠加了,或者节点的 EventHandler 目标为空 二选一。代码绑定就清掉编辑器里的 EventHandler,编辑器绑定就别在代码里重复 on
onLoad 和 start 用错 在 onLoad 里拿别的组件,拿到 undefined onLoad 在组件实例化时触发,早于其他组件的 onLoad 完成;start 在所有组件的 onLoad 都跑完之后、第一次 update 之前 依赖别的组件的初始化,放 start。自己组件内部的初始化,放 onLoad 就行

第一个点:脚本没挂上

这个最坑,因为它什么都不报错。你以为代码跑了,实际上那行代码根本不存在于运行中的世界里。

我现在的做法是加一个断言。玩家脚本启动时检查自己身上有没有某个必需组件,缺就直接打印一条明确的错误,告诉我缺哪个组件、期望什么类型。这条错误信息比"什么反应都没有"强太多。

第二个点:属性没拖进去

@property 声明的属性,在编辑器里是有输入框和拖拽区的。没赋值就是空的。

我踩过的具体例子:给 Player 挂了个 RigidBody2D,脚本里想拿它做移动,结果脚本是在一个子节点上挂的,RigidBody2D 在父节点上。this.getComponent(RigidBody2D) 返回 null,后面一用就炸。

解决办法有两个。一个是在代码里往上找,用 getComponentInParent 之类的接口。另一个更省事:把脚本挂在拥有该组件的那个节点上,别拆开。我后来定了一条规矩,一个脚本只操作自己所在节点的组件和自己直接子节点,需要跨层就用 getComponentInChildren 显式取。

第三个点:坐标系

这是 2D 项目最容易出玄学 bug 的地方。3.x 的 2D 节点用的是和 3D 一致的变换体系,position 是本地坐标。

我出过一次具体的错:关卡滚动的实现是把关卡根节点往反方向移动,玩家看起来像是在往前飞。我一直以为 position 是世界坐标,盯着数值看了半小时。

现在我固定两条规矩。写跟随逻辑用 worldPosition。写局部摆放用 position,并且始终知道父节点是谁。还有一条,角度统一用 MathUtils.toRadian 转弧度,node.angle 存的是弧度,我每次都忘。

第四个点:事件绑两次

点击按钮要触发 onPickOrder。我在代码里 this.node.on(Button.EventType.CLICK, this.onPick, this) 绑了一次,但忘了编辑器里 UI 按钮组件上还挂了一条 EventHandler 指向同一个方法。结果点一下按钮,订单被处理两遍。

我第一反应是"框架有 bug",查了半小时引擎源码才发现是自己写的。

现在的规矩:事件绑定只在一个地方做,团队里约定 UI 事件一律用代码绑,因为代码里能读到、能跳转、能改。编辑器绑的事件只用于调试期临时接线。

第五个点:onLoad 与 start

3.x 组件的生命周期大致是这样:onLoad → onEnable → start → update → onDisable → onDestroy。

onLoad 在组件被加载时触发一次,此时同一节点上其他组件的 onLoad 可能还没跑完。所以在这里面去拿别的组件的状态,是有竞态的。

我现在的分法是:onLoad 里做自己的字段初始化和事件注册,start 里做需要别人已经就绪的事。比如玩家脚本在 onLoad 里注册输入事件,在 start 里读关卡生成器的难度参数。因为关卡生成器是另一个组件,它的 onLoad 得先跑完。

1.4 跑通之后我会立刻做的三件事

最小可跑版本跑通之后,我不急着加玩法。先做三件小事,代价很低,但能省掉后面的麻烦。

第一,给这个最小可跑版本建一个 Git 提交,提交信息写"第一个能跑起来"。这成了一个可以随时回去的锚点。后面动结构出问题,直接回到这个提交看还跑不跑。

第二,把 Player 从场景里的临时节点变成一个预制体。因为再往下走要加障碍、加风区、加订单,必然要复制多个 Player 节点。到时候再改预制体,来得及。

第三,把场景里所有用到的资源路径从代码里的硬编码改成读配置。虽然现在只有一张图两个资源,但等到有三十个资源的时候再改,就要把整个项目翻一遍。

这三件事花的时间加起来不到一小时。

1.5 相关阅读