Day 02 · 搞懂节点树就搞懂了 Cocos —— 场景系统与组件化思想深度解析

0 阅读6分钟

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'
RigidBody2D2D 物理刚体import { RigidBody2D } from 'cc'
BoxCollider2D2D 矩形碰撞体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 场景中,节点的渲染顺序由以下因素决定:

  1. 节点在层级管理器中的位置:下面的节点显示在上面
  2. 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. 实战练习:拖动方块

让我们做一个完整的小练习:创建一个可以拖动的方块。

步骤

  1. 创建场景:Canvas → Sprite(命名为 DragBox
  2. DragBox 节点的 Sprite 组件设置颜色(Content Size: 100x100)
  3. 创建脚本 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 属性绑定
  • ✅ 节点查找、增删、激活/禁用
  • ✅ 三种组件通信方式:直接引用、事件总线、节点事件

⚠️ 常见坑

  1. position 只读:Cocos 3.x 中 node.position 返回只读 Vec3,必须用 node.setPosition() 修改
  2. 忘记 off 事件:组件销毁时必须取消事件监听,否则会引起内存泄漏
  3. find() 性能问题find() 会遍历整个场景树,应在 start() 中缓存,不要在 update() 中使用

下一篇预告

Day 03:TypeScript 写游戏脚本 —— 从 Hello World 到生命周期全掌握


← Day 01 | 系列目录 | Day 03 →