场景、层、精灵、菜单、文字,继承的是同一个基类——所以它们能被摆、被转、被叠。 位置是相对父节点的,世界坐标要一路往根上加。父节点一动,整棵子树跟着动。 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 秒实验
- 把
player的坐标改成 (100, 50) 以外的一组,重跑,看世界坐标跟着变没有。 - 在
layer和player之间再插一层,把层也挪个位置,算算子弹的世界坐标该是多少,再跑对答案。 - 给
order()里加一个 z 是 -5 的节点,看它插在画序的哪个位置。
常见误区
- 「位置就是屏幕坐标。」不是。节点的 position 是相对父节点的,要算屏幕上在哪,得一路加到根。
- 「z 一样就随便排。」一样时看加入顺序,先加的在下。界面上偶发「按钮被挡住」,多半就是这个。
- 「从树上删了就等于销毁了。」不是。 删了只是不在树上了,要是还有别的地方攥着引用,它照样活着,只是画不出来。
自测三问(点开对答案)
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. 删掉一个带三个孩子的节点,需要额外删那三个孩子吗? —— 不需要。删父节点等于把整棵子树从树上摘下来,三个孩子一起走。 但要留意别处还留着对它们的引用——那样它们就成了画不出来的幽灵。树搭好了,下一篇给它挂上动作——那一章最像「魔法」的部分,其实是一行插值。
- ☐ (本篇完,下一篇 4.4 精灵、菜单与动作)