《Cocos 游戏开发实战》2.1 场景、节点与预制体

进编辑器搭出 block-dash 的 main 场景:从层级管理器右键创建 Canvas 开始,讲清节点树、父子关系与变换,把可复用的金币做成预制体,用 instantiate 在运行时复制它,并打开 main.scene 看 _components 数组里的 __type__ 到底存了什么。

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

先建场景。步骤照做即可:

  1. 资源管理器里右键 assets/scenes/ → 创建 → 场景,命名为 main。
  2. 双击 main,编辑器切换到场景编辑视图(层级管理器出现一个空的 main 根)。
  3. 层级管理器空白处右键 → 创建 → UI 组件(部分版本显示为「UI」)→ Canvas。
  4. 编辑器自动生成 Canvas 节点,并在其下挂一个 Camera 子节点。

操作后属性面板应显示:选中 Canvas 节点,Inspector 里有三个组件——UITransform(Content Size 为 960 × 540)、Canvas、Widget。选中子节点 Camera,能看到 Camera 组件,其中 Projection 为 ORTHO、Clear Flags 为 SOLID_COLOR。

接着加两个 UI 节点:

  1. 右键 Canvas → 创建 → 2D 对象 → Label,命名为 ScoreLabel。
  2. 选中 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),而不是每次在场景里手搭。

把节点变成预制体,操作步骤是:

  1. 在层级管理器里选中已经搭好的 Coin 节点。
  2. 把它拖到资源管理器的 assets/prefabs/ 目录里,松手。
  3. 编辑器弹窗询问,确认创建预制体,命名为 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.loadSceneinstantiate
冲突难合并,锁定负责人相对好管,一个模板一人
例子main、level、resultcoin、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 属性面板与序列化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画