2.1 场景、节点与预制体
第 1 章我们把工程搭成了骨架:目录分好、版本规矩定好,但 assets/scenes/ 还是空的。这一章进编辑器,把骨架填上内容。第一步就是回答三个最基础的问题:场景是什么、节点是什么、预制体又是什么。
在 mini2d 里,节点树是运行时用 addChild 一行行拼出来的;在 Cocos 里,同一棵树是在编辑器里搭好、再序列化成文件。理解这个转变,是理解后面所有编辑器工作流的前提。
本节在编辑器里搭出
main场景:一个 Canvas、一个 Camera、几个 UI 节点,再把金币做成可复用的预制体。你会看到.scene与.prefab这两个文件长什么样,以及运行时instantiate到底复制了什么。这是全书第一次大量接触 L3 工程文件。
2.1.1 场景是一棵被序列化的节点树
Cocos 的运行时世界里只有一个根:当前场景。场景本身也是一个节点(Scene 继承自 Node),它的子节点就是你看到的一切。节点负责「在哪」(变换、父子关系),组件负责「是什么」(渲染、逻辑)。
mini2d 里你写:
const root = new Node('root');
const player = new Node('player');
root.addChild(player);
Cocos 里同样存在 addChild,但主路径不是代码——而是编辑器里的层级管理器(Hierarchy)。你搭的树会被序列化成 .scene 文件,游戏启动时引擎把它读回来,还原成一模一样的节点树。所以第 1 章反复强调的 L3 不可运行性,在这里第一次变得具体:场景不是代码,是数据。
2.1.2 动手:从空场景搭出 main
先建场景。步骤照做即可:
- 资源管理器里右键
assets/scenes/→ 创建 → 场景,命名为main。 - 双击
main,编辑器切换到场景编辑视图(层级管理器出现一个空的main根)。 - 层级管理器空白处右键 → 创建 → UI 组件(部分版本显示为「UI」)→ Canvas。
- 编辑器自动生成
Canvas节点,并在其下挂一个Camera子节点。
操作后属性面板应显示:选中 Canvas 节点,Inspector 里有三个组件——UITransform(Content Size 为 960 × 540)、Canvas、Widget。选中子节点 Camera,能看到 Camera 组件,其中 Projection 为 ORTHO、Clear Flags 为 SOLID_COLOR。
接着加两个 UI 节点:
- 右键
Canvas→ 创建 → 2D 对象 → Label,命名为ScoreLabel。 - 选中
ScoreLabel,把Widget的Top勾上、值填20,Left勾上、值填20。
操作后属性面板应显示:ScoreLabel 的 UITransform 下 Content Size 约 100 × 40,Widget 下 Is Align Top 与 Is Align Left 均打勾。
别忘了保存(Ctrl/Cmd + S)。场景不保存,.scene 文件就不会更新,1.3 里那套版本管理也就无从谈起;保存后 git status 里能看到 assets/scenes/main.scene 变了。
2.1.3 main.scene 里到底存了什么
以下片段需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。它是编辑器保存 main.scene 时的结构示意(__id__ 编号与字段会随实际内容变化):
{
"__type__": "cc.SceneAsset",
"_name": "main",
"scene": {
"__id__": 1
}
}
真正的内容在 __id__: 1 指向的那个 cc.Scene 对象里。以下节点片段同样需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。一个节点大致长这样:
{
"__type__": "cc.Node",
"_name": "Canvas",
"_parent": { "__id__": 1 },
"_children": [{ "__id__": 3 }],
"_components": [
{ "__id__": 4 },
{ "__id__": 5 }
],
"_lpos": { "__type__": "cc.Vec3", "x": 480, "y": 270, "z": 0 }
}
三件事值得注意:
_children与_components存的是__id__引用,不是内联对象。整棵树的父子关系、组件归属,全靠这些编号串起来。_lpos是相对父节点的本地坐标(local position)。Canvas 在 960×540 的场景里位于中心,所以是(480, 270)。__type__是组件的类名:cc.UITransform、cc.Canvas、cc.Widget分别对应你在 Inspector 里看到的三个组件。自定义脚本组件则显示为脚本里@ccclass('XXX')注册的名字。
在编辑器里打开 main.scene,你会看到层级管理器是一棵树:main → Canvas → Camera、main → Canvas → ScoreLabel。选中任一节点,Inspector 的组件顺序与文件里 _components 数组的顺序完全一致。
2.1.4 节点:层级、变换、可见性
节点身上最常用的三类状态,在编辑器和代码里都能改:
| 状态 | Inspector 里 | 代码里 |
|---|---|---|
| 层级 | 拖拽调整父子关系 | addChild / setParent / removeFromParent |
| 位置 | Position 三个输入框 | node.setPosition(x, y, z) |
| 可见 | 名字左边的勾选框 | node.active = false |
active 与「渲染可见」是两回事:把 node.active 设为 false,该节点及其所有子节点都不参与更新与渲染;而如果只是把透明度调到 0,节点仍在运行。批量隐藏一整个子树时用 active,做淡入淡出时用 UIOpacity。
层级还有一个容易踩的点:坐标是相对的。把 ScoreLabel 从 Canvas 移到某个面板节点下,它的 Position 数值不变,但屏幕上的位置会跟着父节点走。这正是 2.1.3 里 _lpos 叫「local」的原因。
除了位置,节点还有两个常改的变换量,同样在 Inspector 的 Node 区块里:
| 变换 | Inspector 里 | 代码里 |
|---|---|---|
| 缩放 | Scale 三个输入框 | node.setScale(x, y, z) |
| 旋转 | Rotation(2D 常用 angle) | node.angle = 90 |
angle 只影响 2D 的 Z 轴旋转,单位是度,正值逆时针。做「金币旋转」这类效果时改 angle,做「受击缩放」时改 scale——两者都只在编辑器里预览时生效于当前节点,运行时改则是真正的动画(9.2 会用 tween 把它们连起来)。
2.1.5 预制体:可复用的节点模板
金币、障碍、子弹这类「一个模板要复制很多份」的东西,应该做成预制体(Prefab),而不是每次在场景里手搭。
把节点变成预制体,操作步骤是:
- 在层级管理器里选中已经搭好的
Coin节点。 - 把它拖到资源管理器的
assets/prefabs/目录里,松手。 - 编辑器弹窗询问,确认创建预制体,命名为
coin。
操作后属性面板应显示:层级管理器里 Coin 节点的名字变成蓝色,表示它已经是一个预制体实例;资源管理器 prefabs/ 下出现 coin.prefab。
这里有一个关键概念:预制体实例与场景里的普通节点,长得一样,但「来源」不同。蓝色名字就是那个标记。你在场景里改这个实例,不会改到预制体本身;要让改动回流,得点 Inspector 右上角的「应用」按钮。
2.1.6 运行时用 instantiate 复制预制体
场景里摆的是「初始摆放」,真正无限生成金币要靠代码。下面这个组件把预制体复制出来、摆到指定位置:
import { _decorator, Component, Node, Prefab, instantiate } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('CoinSpawner')
export class CoinSpawner extends Component {
@property(Prefab)
coinPrefab: Prefab | null = null;
@property(Node)
layer: Node | null = null;
spawnAt(x: number, y: number): Node | null {
if (!this.coinPrefab || !this.layer) {
console.warn('[block-dash] 预制体或父节点未绑定');
return null;
}
const coin = instantiate(this.coinPrefab);
coin.setPosition(x, y, 0);
this.layer.addChild(coin);
return coin;
}
}
instantiate(prefab) 返回一个 Node,它是预制体整棵子树的深拷贝——你在预制体里给金币挂的精灵、碰撞、脚本,复制出来的实例全都有。拿到实例后,setPosition 摆位置、addChild 挂到场景里,和 mini2d 的用法完全一致。
两个 @property 是编辑器与代码之间的桥:coinPrefab 在 Inspector 里显示为一个「预制体」拖拽槽,layer 显示为「节点」拖拽槽。绑定是在编辑器里做的——选中挂了这个脚本的节点,把 prefabs/coin.prefab 拖到 Coin Prefab 槽、把层级里的 GameLayer 拖到 Layer 槽。不绑,运行时就是 null,所以上面留了守卫分支。
如果每关要生成几百个金币,instantiate + destroy 的反复分配会让 GC 抖动(13.2 会量化这件事)。到那时我们把它换成对象池:生成时从池里取、回收时 active = false 放回池里,而不是真销毁。2.1.8 的 recycle 就是对象池的雏形,区别只在于「放回池」而不是「销毁」。
2.1.7 场景与预制体:什么时候用哪个
两者都是「节点树的序列化」,区别只在用途:
| 维度 | 场景(.scene) | 预制体(.prefab) |
|---|---|---|
| 数量 | 每个可加载的「画面」一个 | 每个可复用对象一个 |
| 加载 | director.loadScene | instantiate |
| 冲突 | 难合并,锁定负责人 | 相对好管,一个模板一人 |
| 例子 | main、level、result | coin、obstacle、popup |
一条经验法则:如果一个东西会出现两次以上,就该是预制体。 主菜单里那个唯一的「开始」按钮可以是普通节点;但每关都会出现的金币、障碍、道具,必须是预制体。这条法则直接决定了 1.3.5 讲的冲突面能压到多小。
另一个判断依据是加载时机:场景切换由 director.loadScene 驱动,同一时刻只有一个当前场景;而预制体是随时 instantiate 出来的,可以同时存在成百上千个。想做「弹窗」?它是预制体。想做「结算画面」?它可以是场景,也可以是一个预制体叠在主场景上——5.2 会讨论这两种做法的取舍。
2.1.8 查找与销毁节点
场景一多,节点就不能靠拖引用一个个绑定了,总得能按名字找。Node 提供了几个查找 API:
| API | 返回 | 用途 |
|---|---|---|
getChildByName(name) | Node | null | 按名字找直接子节点 |
getChildByPath(path) | Node | null | 按路径找后代,如 sub/Test |
getComponent(Class) | T | null | 在节点上找组件 |
按名字找的写法如下(注意每个返回值都可能是 null,守卫不能省):
import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('HudFinder')
export class HudFinder extends Component {
@property(Node)
canvas: Node | null = null;
findScore(): Node | null {
if (!this.canvas) return null;
return this.canvas.getChildByName('ScoreLabel');
}
recycle(coin: Node): void {
coin.removeFromParent();
coin.destroy();
}
}
recycle 演示了回收一个金币的正确两步:先 removeFromParent 把它从树上摘下来,再 destroy 释放。只调 destroy 不摘,节点会在当前帧末尾才真正移除,期间仍可能被遍历到;先摘再销毁,语义最清晰。
一个反直觉的点:node.active = false 与 destroy() 完全不同。前者只是「关掉」,节点还在、还能再打开、还能被 getChildByName 找到;后者是「删掉」,不可逆。做对象池时用 active 做「回收」,只在真正不再需要时才 destroy。
2.1.9 本节自检清单
-
assets/scenes/main.scene存在,层级为main → Canvas → Camera / ScoreLabel -
Canvas上UITransform的Content Size是960 × 540 -
assets/prefabs/coin.prefab存在,场景里引用它的节点名字是蓝色 -
CoinSpawner的两个@property在 Inspector 里都已绑定 - 运行时
spawnAt能在指定位置生成金币 - 没有把「只出现一次」的节点做成预制体(避免无谓的抽象)
-
main.scene已保存,git status里能看到它 - 层级管理器的节点树与
main.scene里的_components顺序一致 - 回收节点时先
removeFromParent再destroy
2.1.10 工程视角的收口
这一节之后,工程里多了 assets/scenes/main.scene、assets/prefabs/coin.prefab、一个 CoinSpawner 组件。assets/scenes/ 和 assets/prefabs/ 从空目录变成了有内容的目录,.scene 与 .prefab 这两个 L3 文件第一次出现在版本库里。
下一节我们把注意力收到一个更细的问题上:你在 Inspector 里填的每一个值,是怎么变成文件里的数据的? 为什么有的字段改了就保存、有的改了不保存?为什么改了脚本里 @property 的名字,编辑器里绑好的引用会「变空」?答案是序列化——这就是 2.2 的主题。
阅读导航:上一节:目录 · 下一节:2.2 属性面板与序列化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。