Day 02 · 搞懂节点树就搞懂了 Cocos —— 场景系统与组件化思想深度解析
学习目标:深入理解 Node-Component 架构,掌握 Transform 变换、节点查找与层级关系
预计时间:2.5 小时
难度:⭐⭐☆☆☆
核心概念:一切皆节点
Cocos Creator 的世界观非常简单:游戏世界 = 节点树。
Scene(场景)
├── Canvas
│ ├── Background(背景图)
│ ├── Player(玩家)
│ │ ├── Body(身体精灵)
│ │ └── HealthBar(血量条)
│ └── UI
│ ├── ScoreLabel(分数文字)
│ └── PauseButton(暂停按钮)
└── GameManager(游戏管理器,通常无渲染组件)
每个节点(Node)本身不做任何事情,它只负责位置、旋转、缩放。真正的功能由**挂载在节点上的组件(Component)**来实现。
类比理解:
- 节点 = 演员(只知道站在哪里)
- 组件 = 演员的技能(会唱歌、会跳舞、会变形)
1. 节点(Node)详解
1.1 Transform 三要素
选中任意节点,在 Inspector 中可以看到 Transform 组件:
import { _decorator, Component, Vec3 } from 'cc';
const { ccclass } = _decorator;
@ccclass('TransformDemo')
export class TransformDemo extends Component {
start() {
// 获取位置
const pos = this.node.position;
console.log('当前位置:', pos.x, pos.y, pos.z);
// 设置位置(注意:3.x 中 position 是只读属性,用 setPosition)
this.node.setPosition(100, 200, 0);
// 或者使用 Vec3
this.node.setPosition(new Vec3(100, 200, 0));
// 旋转(欧拉角,单位:度)
this.node.setRotationFromEuler(0, 0, 45); // 绕Z轴旋转45度
// 缩放
this.node.setScale(2, 2, 1); // 宽高各放大2倍
// 获取世界坐标(相对于场景原点,而非父节点)
const worldPos = this.node.worldPosition;
console.log('世界坐标:', worldPos);
}
}
1.2 坐标系
Cocos 使用左手坐标系,屏幕中心为原点:
Y+(上)
↑
|
|
+--------→ X+(右)
/
/
Z+(朝向屏幕外)
本地坐标 vs 世界坐标:
- 本地坐标(Local):相对于父节点的坐标
- 世界坐标(World):相对于场景原点的坐标
// 获取本地坐标
const localPos = this.node.position; // 相对父节点
// 获取世界坐标
const worldPos = this.node.worldPosition; // 相对场景原点
// 坐标系转换
import { Vec3 } from 'cc';
const worldVec = new Vec3();
// 本地坐标转世界坐标
this.node.parent!.getWorldPosition(worldVec);
2. 组件(Component)系统
2.1 组件的本质
每个 @ccclass 装饰的 TypeScript 类就是一个组件。组件必须挂载到节点上才能运行。
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('MyComponent') // 注册组件名称(必须唯一)
export class MyComponent extends Component {
// @property 让属性显示在 Inspector 面板中
@property
speed: number = 100;
@property
playerName: string = '勇者';
}
2.2 常用内置组件
| 组件 | 功能 | 导入方式 |
|---|---|---|
Sprite | 显示图片(精灵) | import { Sprite } from 'cc' |
Label | 显示文字 | import { Label } from 'cc' |
Button | 按钮交互 | import { Button } from 'cc' |
RigidBody2D | 2D 物理刚体 | import { RigidBody2D } from 'cc' |
BoxCollider2D | 2D 矩形碰撞体 | import { BoxCollider2D } from 'cc' |
Animation | 动画播放 | import { Animation } from 'cc' |
AudioSource | 音频播放 | import { AudioSource } from 'cc' |
Camera | 摄像机 | import { Camera } from 'cc' |
2.3 在脚本中访问组件
import { _decorator, Component, Label, Sprite, SpriteFrame } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('PlayerController')
export class PlayerController extends Component {
// 方式1:在 Inspector 中拖拽赋值(推荐)
@property(Label)
nameLabel: Label = null!;
@property(Sprite)
bodySprite: Sprite = null!;
start() {
// 方式2:通过代码查找(运行时查找)
const label = this.getComponent(Label);
if (label) {
label.string = '玩家1';
}
// 修改 Inspector 中绑定的 Label
this.nameLabel.string = '英雄';
this.nameLabel.color.fromHEX('#FF8800');
}
}
3. 节点操作
3.1 查找节点
import { _decorator, Component, Node, find } from 'cc';
const { ccclass } = _decorator;
@ccclass('NodeFinder')
export class NodeFinder extends Component {
start() {
// 方式1:通过场景路径查找(全局查找,性能开销较大)
const player = find('Canvas/Player');
// 方式2:查找子节点
const healthBar = this.node.getChildByName('HealthBar');
// 方式3:通过路径查找子节点
const body = this.node.getChildByPath('Player/Body');
// 方式4:获取所有子节点
const children = this.node.children;
children.forEach(child => {
console.log('子节点:', child.name);
});
// 注意:find() 效率较低,建议用 @property 绑定或在 start() 时缓存
const cachedNode = find('Canvas/GameUI')!;
}
}
3.2 节点的增删
import { _decorator, Component, Node, instantiate, Prefab } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('NodeManager')
export class NodeManager extends Component {
@property(Prefab)
bulletPrefab: Prefab = null!; // 预制体(后面章节详细讲)
spawnBullet(x: number, y: number) {
// 实例化预制体
const bullet = instantiate(this.bulletPrefab);
// 添加到当前节点的父节点下
this.node.parent!.addChild(bullet);
// 设置位置
bullet.setPosition(x, y, 0);
}
destroyNode(node: Node) {
// 销毁节点(从场景中移除并释放内存)
node.destroy();
}
removeNodeFromParent(node: Node) {
// 从父节点移除,但不销毁(可以重新添加)
node.removeFromParent();
}
}
3.3 节点激活/禁用
// 禁用节点(节点及子节点都不渲染、不执行脚本)
this.node.active = false;
// 激活节点
this.node.active = true;
// 禁用组件(组件不执行,但节点仍然存在)
this.enabled = false;
4. 父子关系与层级
4.1 父子关系的影响
父节点的变换会影响子节点:
父节点 position: (100, 0)
└── 子节点 position: (50, 0)
└── 子节点实际世界位置: (150, 0)
父节点 scale: (2, 2)
└── 子节点 scale: (1, 1)
└── 子节点实际显示大小: 原始的 2 倍
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
@ccclass('ParentDemo')
export class ParentDemo extends Component {
start() {
// 获取父节点
const parent = this.node.parent;
console.log('父节点名称:', parent?.name);
// 改变父节点(重新挂载)
const newParent = this.node.scene.getChildByName('Canvas')!;
this.node.setParent(newParent);
// setParent 的第二个参数 worldPositionStays:
// true = 保持世界位置不变(视觉位置不变,本地坐标变化)
// false = 保持本地坐标不变(视觉位置会变)
this.node.setParent(newParent, true);
}
}
4.2 渲染层级(zIndex / Layer)
在 2D 场景中,节点的渲染顺序由以下因素决定:
- 节点在层级管理器中的位置:下面的节点显示在上面
- Layer(层):可以控制哪些摄像机渲染哪些节点
// 修改节点层级顺序
this.node.setSiblingIndex(0); // 移到同级第一位(最底层渲染)
this.node.setSiblingIndex(this.node.parent!.children.length - 1); // 移到最上层
5. 组件通信
在 Cocos 项目中,不同组件/脚本间的通信有几种常见方式:
5.1 直接引用(最简单)
import { _decorator, Component } from 'cc';
import { PlayerController } from './PlayerController';
const { ccclass, property } = _decorator;
@ccclass('GameManager')
export class GameManager extends Component {
@property(PlayerController)
player: PlayerController = null!;
start() {
// 直接调用另一个组件的方法
this.player.takeDamage(10);
}
}
5.2 事件系统(解耦合)
import { _decorator, Component, EventTarget } from 'cc';
const { ccclass } = _decorator;
// 全局事件总线(EventTarget 单例)
export const GameEvent = new EventTarget();
// 发送事件的脚本
@ccclass('EnemyController')
export class EnemyController extends Component {
die() {
// 发送事件,携带参数
GameEvent.emit('enemy-die', { score: 100, position: this.node.position });
this.node.destroy();
}
}
// 监听事件的脚本
@ccclass('ScoreManager')
export class ScoreManager extends Component {
start() {
GameEvent.on('enemy-die', this.onEnemyDie, this);
}
onEnemyDie(data: { score: number }) {
console.log('敌人死亡,得分:', data.score);
}
onDestroy() {
// 重要:组件销毁时必须取消监听,防止内存泄漏!
GameEvent.off('enemy-die', this.onEnemyDie, this);
}
}
5.3 节点事件
import { _decorator, Component, Node, Input, input, EventTouch } from 'cc';
const { ccclass } = _decorator;
@ccclass('TouchHandler')
export class TouchHandler extends Component {
start() {
// 监听节点的触摸事件
this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
this.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
// 监听全局键盘输入
input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
}
onTouchStart(event: EventTouch) {
const pos = event.getLocation(); // 屏幕坐标
console.log('触摸位置:', pos.x, pos.y);
}
onTouchMove(event: EventTouch) {
const delta = event.getDelta(); // 移动量
this.node.setPosition(
this.node.position.x + delta.x,
this.node.position.y + delta.y,
0
);
}
onTouchEnd(event: EventTouch) {
console.log('触摸结束');
}
onKeyDown(event: any) {
console.log('按键:', event.keyCode);
}
onDestroy() {
this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
this.node.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this);
input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
}
}
6. 实战练习:拖动方块
让我们做一个完整的小练习:创建一个可以拖动的方块。
步骤
- 创建场景:Canvas → Sprite(命名为
DragBox) - 给
DragBox节点的 Sprite 组件设置颜色(Content Size: 100x100) - 创建脚本
DragBox.ts:
import { _decorator, Component, Node, EventTouch, UITransform, Vec3 } from 'cc';
const { ccclass } = _decorator;
@ccclass('DragBox')
export class DragBox extends Component {
private _isDragging: boolean = false;
private _startOffset: Vec3 = new Vec3();
start() {
this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
this.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
}
onTouchStart(event: EventTouch) {
this._isDragging = true;
// 记录触摸点与节点中心的偏移(世界坐标)
const touchWorldPos = event.getUILocation();
this._startOffset.set(
this.node.worldPosition.x - touchWorldPos.x,
this.node.worldPosition.y - touchWorldPos.y,
0
);
event.propagationStopped = true; // 阻止事件向上传递
}
onTouchMove(event: EventTouch) {
if (!this._isDragging) return;
const touchWorldPos = event.getUILocation();
this.node.setWorldPosition(
touchWorldPos.x + this._startOffset.x,
touchWorldPos.y + this._startOffset.y,
0
);
}
onTouchEnd(event: EventTouch) {
this._isDragging = false;
}
onDestroy() {
this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
this.node.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this);
}
}
7. 今日总结
今天我们掌握了:
- ✅ Cocos 节点树(Scene Tree)的核心概念
- ✅ Transform:Position、Rotation、Scale 的本地坐标 vs 世界坐标
- ✅ 组件(Component)系统与
@property属性绑定 - ✅ 节点查找、增删、激活/禁用
- ✅ 三种组件通信方式:直接引用、事件总线、节点事件
⚠️ 常见坑
- position 只读:Cocos 3.x 中
node.position返回只读 Vec3,必须用node.setPosition()修改 - 忘记 off 事件:组件销毁时必须取消事件监听,否则会引起内存泄漏
- find() 性能问题:
find()会遍历整个场景树,应在start()中缓存,不要在update()中使用
下一篇预告
Day 03:TypeScript 写游戏脚本 —— 从 Hello World 到生命周期全掌握