🎮 第4章 Cocos2d Ⅰ
📄 一句话概括

场景、层、精灵、菜单、文字,继承的是同一个基类——所以它们能被摆、被转、被叠。 位置是相对父节点的,世界坐标要一路往根上加。父节点一动,整棵子树跟着动。 z 决定谁盖谁;z 一样时,后加进来的画在上面。

📝 本章和前面几章的关系

上一篇讲清了「位置怎么记」和「谁决定演哪一屏」,这一篇讲这些东西怎么摞成一棵树。 教材 4.4 节下面三小节:【CocosNode】【Scene】【Layer】。 三节看着像三个独立的东西,其实是一个东西的三个层次:节点是原子,层是容器,场景是舞台。 这一篇的树、坐标、z 排序、节点数增减,都是纯 Python 建出来真跑的。

一、一个基类管所有东西

CocosNode 是所有能在画面上存在的东西的基类。场景是它、层是它、精灵是它、菜单是它、一行文字也是它。

属性 意思 默认值
position 相对父节点的坐标 (0, 0)
rotation 转了多少度 0
scale 缩放倍数 1.0
visible 显示还是藏起来 True
children 子节点列表 空
z 谁盖谁,大的在上 0

六个属性,撑起了整个引擎的显示部分。只要有这六样,一个节点就能被摆、被转、被藏、被叠。 统一基类的好处是「整体操作」变得免费:想藏起一整块界面? 把它的 visible 设成 False,子节点全看不见,不用挨个设。想整块挪走?改一个 position。

二、树怎么长

演示建了最小的一棵树:一个场景、一层、底下挂三个精灵。

场景
└── 层
    ├── 背景   z = -10
    ├── 玩家   z =   0
    └── 子弹   z =   5

真的游戏会长得多:场景下面挂背景层、游戏层、HUD 层,游戏层下面挂几十个精灵。 但结构永远是这一棵树,只是枝多。 为什么非得是树,不能用一个大列表?因为列表里你没法表达「这一堆是一伙的」。 树能表达,代价是每个节点要知道自己爹是谁。

三、位置是相对的

演示第三段把每个节点的「自己的坐标」和「世界坐标」并排打了出来。 子弹自己写的是 (10, 20),它的世界坐标也是 (10, 20)——因为上面两层(层和场景)都在原点。 这时候世界坐标 = 自己的坐标,看不出区别。真正见功夫的是下一段。 把层挪到 (50, 30),什么子节点都没碰,三个精灵的世界坐标全变了:背景 (0, 0) 变 (50, 30), 玩家 (100, 50) 变 (150, 80),子弹 (10, 20) 变 (60, 50)。

算法就是一路往根上加:世界坐标 = 自己的坐标 + 父的坐标 + 父的父的坐标,直到根。 这就是节点树最值钱的地方。 做「镜头跟随」的时候,你不用去改主角的坐标, 只把整个游戏层往反方向挪——主角在层里还是原地踏步,看起来却是世界在后退。

四、z 与加入顺序

谁盖谁,看 z。z 小的先画,后画的盖住先画的。 演示里背景 z = -10、玩家 z = 0、子弹 z = 5,所以画序就是背景、玩家、子弹。 背景用负数不是为了好看,是为了「不用管别人」——以后再加十个精灵,z 用默认的 0,它们全在背景上面。 z 一样的时候比什么?比加入顺序。 演示又塞了两个 z 都是 0 的节点,结果它们排在玩家之后、子弹之前——先加的在下,后加的在上。

这条规则很实用:HUD 那层你就写成 z = 100,怎么加都不会被挡住。 给层次留出间隔,比每次去调顺序省事。

五、删一个节点带走整棵子树

演示数了一下节点数:场景、层、背景、玩家、子弹,一共 5 个。 挂一个带两个孩子的临时支上去,节点数变成 8。把那个支删掉,回到 5。 一行删除顶三行——因为你删的是父节点,两个孩子跟着走。

这句话有个反面:删了父节点之后,你要是还攥着子节点的引用,它其实已经不在树上了,更新它、 移动它都不会有任何效果。这种「幽灵节点」是新手上手引擎后最常见的怪 bug。 反过来说,从树上摘下来的节点不会自动销毁,还能再挂回去。 做对象池(子弹打完了收起来,下次复用)就靠这个特性。

六、进场景和出场景的通知

节点进场景、出场景时会收到通知:on_enter 和 on_exit。演示把收到通知的顺序打了出来。 顺序是先父后子:先进场景、再进层、然后背景、玩家、子弹。出去的时候也一样。 为什么父节点要先知道? 因为父节点可能是「一层要开背景音乐的音乐层」,它得先把自己准备好,子节点进来才有地方待。

实际用处:进场景时初始化计分、开背景音乐;出场景时停音乐、把定时器关掉。 没有这两个通知,你就得在每个切换的地方手动收拾一遍,漏一处就是内存泄漏。

七、Scene 和 Layer 各管什么

两个都是节点,都能挂子节点,看着像重复设计了。其实分工很明确。

对比项 Scene 场景 Layer 层
是什么 一屏戏,导演管的就是它 场景里的一摞,可以叠好几层
管什么 挂层、进出的通知 挂精灵、接事件、排 z
能有几个 栈里可以有好几个 一个场景里想放几个放几个
做菜单 一般不直接放按钮 放菜单、放背景、放 HUD

一句话:场景管流程,层管画面。 还有一条规矩值得记:精灵挂在层上,不要直接挂到场景上。 挂场景上能显示,但你以后想整体挪动游戏内容(做镜头、做震动),就没地方下手了。

八、动手 Demo:节点树:位置、z 顺序与进出通知

先用纯 Python 建一棵节点树,把每个节点的世界坐标算出来。 再把父节点挪一下,看三个子节点的世界坐标怎么跟着变。 然后试 z 排序、同 z 的先后、以及删父节点带走子树,最后跑一遍进出的通知顺序。

class Node:
    def __init__(self, name, x=0, y=0):
        self.name, self.x, self.y = name, x, y
        self.z, self.parent, self.children = 0, None, []

    def add(self, child, z=0):
        child.z, child.parent = z, self
        self.children.append(child)
        return child

    def remove(self, child):
        self.children.remove(child)
        child.parent = None

    def world(self):
        x, y, p = self.x, self.y, self.parent
        while p is not None:
            x, y = x + p.x, y + p.y
            p = p.parent
        return x, y

    def count(self):
        return 1 + sum(c.count() for c in self.children)

    def walk(self, out=None):
        out = [] if out is None else out
        out.append(self)
        for c in self.children:
            c.walk(out)
        return out

def info(s):
    print('  ' + s)

def sec(t):
    print(); print('  ' + t); print('  ' + '-' * 68)

# ① 建树
sec('① 建一棵树')
scene = Node('场景')
layer = scene.add(Node('层'))
bg = layer.add(Node('背景'), z=-10)
player = layer.add(Node('玩家', 100, 50), z=0)
bullet = layer.add(Node('子弹', 10, 20), z=5)
for n in scene.walk():
    info('%-4s 自己 (%d, %d)  世界 (%d, %d)'
         % ((n.name,) + (n.x, n.y) + n.world()))

# ② 父动,子全动
sec('② 把层挪到 (50, 30)')
layer.x, layer.y = 50, 30
for n in (bg, player, bullet):
    info('%-4s 自己 (%d, %d)  世界 (%d, %d)'
         % ((n.name,) + (n.x, n.y) + n.world()))
layer.x, layer.y = 0, 0

# ③ z 排序
sec('③ 先画谁后画谁')
def order():
    idx = sorted(range(len(layer.children)),
                 key=lambda i: (layer.children[i].z, i))
    return [layer.children[i].name for i in idx]
info('现在的画序:%s' % ' -> '.join(order()))
layer.add(Node('同层甲'))
layer.add(Node('同层乙'))
info('加两个 z = 0 之后:%s' % ' -> '.join(order()))

# ④ 删父节点带走子树
sec('④ 删一个节点带走几个')
info('现在节点数 %d' % scene.count())
branch = Node('临时支')
branch.add(Node('甲')); branch.add(Node('乙'))
layer.add(branch, z=1)
info('挂上带两个孩子的支:%d' % scene.count())
layer.remove(branch)
info('删掉那个支:%d' % scene.count())

实跑输出:

--------------------------------------------------------------------
节点、场景与层:一棵树撑起整个画面
--------------------------------------------------------------------

  ① CocosNode 是最小的单位
  --------------------------------------------------------------------
  场景、层、精灵、菜单、文字,在 Cocos2d 里都继承同一个基类 CocosNode。

| 属性 | 意思 | 默认值 |
|---|---|---|
| position | 相对父节点的坐标 | (0, 0) |
| rotation | 转了多少度 | 0 |
| scale | 缩放倍数 | 1.0 |
| visible | 显示还是藏起来 | True |
| children | 子节点列表 | 空 |
| z | 谁盖谁,大的在上 | 0 |

  ! 只要有这六样,一个节点就能被摆、被转、被藏、被叠。

  ② 树建起来是这样
  --------------------------------------------------------------------
    场景
    └── 层
        ├── 背景   z = -10
        ├── 玩家   z =   0
        └── 子弹   z =   5

  ③ 位置是相对的,世界坐标要一路加
  --------------------------------------------------------------------
  子弹自己写的是 (10, 20),但它挂在层上,层又挂在场景上。

| 节点 | 自己的坐标 | 父节点 | 世界坐标 | 第几层 |
|---|---|---|---:|---:|
| 场景 | (0, 0) | (无) | (0, 0) | 0 |
| 层 | (0, 0) | 场景 | (0, 0) | 1 |
| 背景 | (0, 0) | 层 | (0, 0) | 2 |
| 玩家 | (100, 50) | 层 | (100, 50) | 2 |
| 子弹 | (10, 20) | 层 | (10, 20) | 2 |

  ! 这里所有父节点都在原点,所以世界坐标刚好等于自己的坐标。

  ④ 父节点一动,整棵子树跟着动
  --------------------------------------------------------------------
  把层挪到 (50, 30),其他什么都没改:

| 节点 | 自己的坐标(没变) | 世界坐标(全变了) |
|---|---|---|
| 背景 | (0, 0) | (50, 30) |
| 玩家 | (100, 50) | (150, 80) |
| 子弹 | (10, 20) | (60, 50) |

  ! 这就是节点树最值钱的地方:移动一整层,比逐个改每个精灵省事得多。

  ⑤ z 决定谁盖谁,同 z 看加入顺序
  --------------------------------------------------------------------
  层的子节点按 z 从小到大画,后画的盖住先画的:
    第 1 个画:背景   z = -10
    第 2 个画:玩家   z =   0
    第 3 个画:子弹   z =   5
  再塞两个 z 都是 0 的节点:
    第 1 个画:背景   z = -10
    第 2 个画:玩家   z =   0
    第 3 个画:同层甲  z =   0
    第 4 个画:同层乙  z =   0
    第 5 个画:子弹   z =   5
  ! z 一样时,先加进来的先画,也就是被后加的盖住。要压住别人就把 z 调大。

  ⑥ 删一个节点,带走的是整棵子树
  --------------------------------------------------------------------
  当前整棵树节点数:5
  挂上一个带两个孩子的支,节点数变成:8
  删掉这个支,节点数回到:5
  ! 删父节点不用挨个删孩子——整棵子树一起摘掉。自己的引用记得清,不然内存不还。

  ⑦ 进场景和出场景,通知是按树的顺序走的
  --------------------------------------------------------------------
  进场景时收到通知的顺序:场景 → 层 → 背景 → 玩家 → 子弹
  出场景时收到通知的顺序:场景 → 层 → 背景 → 玩家 → 子弹
  ! 进出都是先父后子。父节点先拿到通知,才有机会做准备和收尾。

  ⑧ Scene 和 Layer 各管什么
  --------------------------------------------------------------------

| 对比项 | Scene 场景 | Layer 层 |
|---|---|---|
| 是什么 | 一屏戏,导演管的就是它 | 场景里的一摞,可以叠好几层 |
| 管什么 | 挂层、进出的通知 | 挂精灵、接事件、排 z |
| 能有几个 | 栈里可以有好几个 | 一个场景里想放几个放几个 |
| 做菜单 | 一般不直接放按钮 | 放菜单、放背景、放 HUD |

  ! 一句话:场景管流程,层管画面。精灵永远挂在层上,不直接挂场景。

这段 Node 类只有二十来行,但它把 CocosNode 的三个关键语义全实现了:父子关系、相对坐标、z 排序。 自己写一遍,再看引擎的文档就有「哦,就这」的感觉。 world() 那个 while 循环值得盯两眼。它是「往上爬,一路加」,爬到根为止。 父节点越多,这个循环越长——深树是有代价的。 演示第二段是全篇的重点:层挪到 (50, 30),三个精灵自己的坐标一个都没变,世界坐标全跟着挪。 这就是「整层移动」比「逐个移动」强的地方。 z 排序那一段藏着一个细节:排序的 key 是 (z, i),那个 i 就是加入顺序。 引擎内部也是这么干的——用「z + 编号」当排序键,稳定又便宜。 最后那段 5 → 8 → 5 是真实计数。 删除父节点不需要遍历子节点,摘一次就够。但你要记得把外部引用也清掉, 不然会出现「对着已经不在树上的节点改坐标」。

30 秒实验

  1. 把 player 的坐标改成 (100, 50) 以外的一组,重跑,看世界坐标跟着变没有。
  2. 在 layer 和 player 之间再插一层,把层也挪个位置,算算子弹的世界坐标该是多少,再跑对答案。
  3. 给 order() 里加一个 z 是 -5 的节点,看它插在画序的哪个位置。

常见误区

  1. 「位置就是屏幕坐标。」不是。节点的 position 是相对父节点的,要算屏幕上在哪,得一路加到根。
  2. 「z 一样就随便排。」一样时看加入顺序,先加的在下。界面上偶发「按钮被挡住」,多半就是这个。
  3. 「从树上删了就等于销毁了。」不是。 删了只是不在树上了,要是还有别的地方攥着引用,它照样活着,只是画不出来。
自测三问(点开对答案) 1. 一个精灵的 position 是 (10, 20),它爹是 (50, 30),它爷爷是 (0, 0),世界坐标是多少? —— (60, 50)。就是一路加起来:10+50+0、20+30+0。 **注意加的是每一层的 position,不是只加最近一层。** 2. 两个精灵 z 都是 0,先加 A 后加 B,谁盖谁? —— B 盖 A。z 一样时按加入顺序排,先加的先画,后画的后覆盖。 想让 A 在上面,就把 A 的 z 调大,或者用 `reorder_child` 调顺序。 3. 删掉一个带三个孩子的节点,需要额外删那三个孩子吗? —— 不需要。删父节点等于把整棵子树从树上摘下来,三个孩子一起走。 但要留意别处还留着对它们的引用——那样它们就成了画不出来的幽灵。

树搭好了,下一篇给它挂上动作——那一章最像「魔法」的部分,其实是一行插值。

相关笔记