Cocos2d 原点在左下、y 往上长,跟 Pygame 正好上下翻过来。 锚点不是位置,是「拿自身的哪个点去对位置」,它还决定旋转绕谁转。 导演就是一个场景栈:run 压、push 叠、pop 弹、replace 换顶。
上一篇把架子和安装问题都说清了,这一篇讲两件更底层的事:位置怎么记、画面谁说了算。 教材 4.3 节下面三小节:【基本概念】【坐标系】【director】。 这三节合起来回答三个问题:东西摆在哪、按谁的规矩摆、现在演哪一屏。 本篇的数字全是真算的——换算表、锚点表、转 90 度后的坐标、场景栈的每一步。
一、y 轴反过来了
Pygame 的原点在左上角,y 往下长;Cocos2d 照搬 OpenGL,原点在左下角,y 往上长。 同一个点,在两套里记法完全不同。换算只有一句:y_cocos = 屏幕高 - y_pygame。 演示第一段拿 640×480 的屏幕算了四个点。 最典型的是左上角:Pygame 里是 (0, 0),Cocos2d 里是 (0, 479)。 同一个角,两套记法差了一整个屏幕高。
差那个 1 要说清楚:Pygame 的坐标指的是格子的左上角,Cocos2d 指的是格子的左下角。 一个像素是一个格子,不是一条线,所以换算时减不减 1 都说得通,看你按哪种约定。 做像素级对齐的时候才需要较真,平时差一个像素没人看得出来。
二、为什么要反着来
两个原因。一是数学:课本上的坐标系就是 y 向上,讲旋转、讲向量,按这套算最顺。 二是历史:图像的存储格式是从上往下逐行扫的,所以「第一行在最上面」成了默认, Pygame 直接把这个顺序当成了坐标。 Cocos2d 选了数学那套,于是每次要贴图、要算触摸位置,内部都要翻一次。这是引擎替你付的账。
对你自己的项目,结论只有一条:转换只在一处做。要么全用一套,要么定一个函数专门换算, 绝不要「这里翻一下、那里不翻」。混着来的代码,出一个差 480 的 bug 能查一下午。
三、锚点:哪个点算我的位置
Cocos2d 里精灵有个 anchor,取值 0 到 1,是这个点在自身上的比例位置。 (0, 0) 是左下角,(0.5, 0.5) 是中心,(1, 1) 是右上角。 演示算了一个 100×80、position = (300, 200) 的精灵,三种锚点下的左下角和右上角:
| 锚点 | 左下角 | 右上角 |
|---|---|---|
| (0.0, 0.0) 左下 | (300, 200) | (400, 280) |
| (0.5, 0.5) 中心 | (250, 160) | (350, 240) |
| (1.0, 1.0) 右上 | (200, 120) | (300, 200) |
看第一行和第三行:同样是 position = (300, 200),图的实际位置差了整整一个身位。 所以锚点不改 position,改的是「position 对到图上的哪一点」。 position 一个数都没动,画面上图却挪了——这就是很多人第一次被绕进去的地方。 锚点默认是中心 (0.5, 0.5),这跟 Pygame 默认拿左上角对位置正好相反。 从 Pygame 转过来的时候,第一件要改的习惯就是这个。
四、旋转为什么绕锚点转
因为旋转、缩放都是「保持锚点那个点不动,其他点绕它转」。 演示拿上面那个精灵算了:左下角 (250, 160),中心 (300, 200),把右上角那一点绕中心转 90 度。 结果是 (350, 240) 变成 (260, 250)。 看着抽象,画一下就想通了:右上角在中心的右上方,绕中心逆时针转 90 度,就跑到中心的左上方去了。 水平差 50、垂直差 40,转完变成水平差 -40、垂直差 50。
这就是为什么精灵图通常以中心为锚点。 以左下角为锚点转 90 度,整张图会甩到旁边去,因为它绕着左下角那个角转。 做「原地转圈」的效果,锚点必须在中心;做「绕某个点公转」,就把锚点设到那个点上。 一个参数,两种完全不同的动画。
五、导演:一个场景栈
导演(director)听名字很玄,实现上就是管一个列表——场景栈。
演示真跑了一遍五步,每一步都把栈打出来:run 主菜单之后栈是 [主菜单];
push 设置之后是 [主菜单, 设置];pop 一下回到 [主菜单];push 游戏变成 [主菜单, 游戏];
replace 结算把顶换成 [主菜单, 结算];再 pop 回 [主菜单]。
| 方法 | 做什么 | 典型场景 |
|---|---|---|
| run | 清空重来,压一个新场景 | 启动 |
| push | 叠一层,下面那层还在 | 暂停菜单、设置、背包 |
| pop | 弹掉最上面那层 | 关掉暂停回游戏 |
| replace | 换掉最上面那层 | 菜单切到游戏、游戏切到结算 |
只有四个动作,但组合出来的流程够用了。大多数 2D 游戏的画面切换,都是这四句话的不同排列。
六、什么时候该 push
push 和 replace 的区别只有一句:底下的场景还要不要留着。 暂停菜单一定要 push。因为关掉暂停之后,游戏要接着刚才那一秒继续跑——分数、位置、敌机全在。 replace 掉的话,底下的游戏场景就被扔了,回来只能重开。 菜单切游戏一定是 replace。主菜单留着没意义,它也不该在内存里占着。
演示最后停在 [主菜单],然后说「栈空了,游戏结束」。
导演手里一个场景都没有的时候,程序就该退出了。
反过来说,最外层一定要有一个场景压着。游戏里所有「黑屏卡住」的 bug,一半都是栈被弹空了还在等事件。
七、动手 Demo:坐标系换算、锚点与场景栈
先算四个点在两套坐标系里的记法,看清那个「翻过来」。 再算三种锚点下同一张图的左下角和右上角,顺带把右上角绕中心转 90 度。 最后真跑一遍场景栈的五步,每一步都把栈打出来。
W, H = 640, 480
def info(s):
print(' ' + s)
def note(s):
print(' ! ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
# ① 两套坐标系
sec('① 两套坐标系,y 的方向反了')
for sx, sy in ((0, 0), (100, 50), (320, 240), (639, 479)):
info('Pygame (%d, %d) -> Cocos2d (%d, %d)'
% (sx, sy, sx, H - 1 - sy))
# ② 锚点
sec('② 锚点决定左下角在哪')
def corner(pos, w, h, ax, ay):
return (pos[0] - w * ax, pos[1] - h * ay)
for ax, ay, tag in ((0.0, 0.0, '左下'), (0.5, 0.5, '中心'),
(1.0, 1.0, '右上')):
cx, cy = corner((300, 200), 100, 80, ax, ay)
info('锚点 %s 左下角 (%d, %d) 右上角 (%d, %d)'
% (tag, cx, cy, cx + 100, cy + 80))
# ③ 绕中心转 90 度
sec('③ 旋转绕的是锚点')
import math
cx, cy = corner((300, 200), 100, 80, 0.5, 0.5)
ox, oy = cx + 50, cy + 40 # 中心
px, py = cx + 100, cy + 80 # 右上角
dx, dy = px - ox, py - oy
for deg in (0, 90):
rad = math.radians(deg)
nx = ox + dx * math.cos(rad) - dy * math.sin(rad)
ny = oy + dx * math.sin(rad) + dy * math.cos(rad)
info('转 %2d 度后:(%.1f, %.1f)' % (deg, nx, ny))
# ④ 场景栈
sec('④ 导演管的是一个栈')
stack = []
def run(s):
stack.append(s); info('run(%s) -> %s' % (s, stack))
def push(s):
stack.append(s); info('push(%s) -> %s' % (s, stack))
def pop():
info('pop() -> 弹出 %s,栈 %s' % (stack.pop(), stack))
def replace(s):
old = stack.pop(); stack.append(s)
info('replace(%s) -> 换掉 %s,栈 %s' % (s, old, stack))
run('主菜单'); push('设置'); pop(); push('游戏'); replace('结算'); pop()
实跑输出:
--------------------------------------------------------------------
坐标系、锚点与导演
--------------------------------------------------------------------
① 两套坐标系,y 的方向反了
--------------------------------------------------------------------
Pygame 的原点在左上,y 往下长;Cocos2d 照搬 OpenGL,原点在左下,y 往上长。
同一个点,在两套里记法不同。换算就一句:y_cocos = 屏幕高 - y_pygame。
| Pygame 里的点 | 换成 Cocos2d(减 1) | 换成 Cocos2d(不减) |
|---|---|---|
| (0, 0) | (0, 479) | (0, 480) |
| (100, 50) | (100, 429) | (100, 430) |
| (320, 240) | (320, 239) | (320, 240) |
| (639, 479) | (639, 0) | (639, 1) |
! 减不减 1 看约定。差 1 个像素,做像素级对齐时才要较真。
② 锚点:哪个点算「我的位置」
--------------------------------------------------------------------
Cocos2d 里精灵有个 anchor,取值 0 到 1,是这个点在自身上的比例位置。
| 锚点 | 指的是 | 换算成左下角 |
|---|---|---|
| (0, 0) | 左下角 | 就是 position 本身 |
| (0.5, 0.5) | 中心 | position 减去宽高的一半 |
| (1, 1) | 右上角 | position 减去整个宽高 |
设精灵 100×80、position = (300, 200):
| 锚点 | 左下角 | 右上角 |
|---|---|---|
| (0.0, 0.0) 左下 | (300, 200) | (400, 280) |
| (0.5, 0.5) 中心 | (250, 160) | (350, 240) |
| (1.0, 1.0) 右上 | (200, 120) | (300, 200) |
! 锚点只改「怎么算」,不改 position 本身。旋转、缩放都绕锚点转。
③ 旋转为什么绕锚点转
--------------------------------------------------------------------
精灵左下角 (250, 160),中心 (300, 200)
把右上角那一点绕中心转 90 度:
转 0 度后:(350.0, 240.0)
转 90 度后:(260.0, 250.0)
! 绕别的点转会得到不同结果。锚点就是「绕谁转」的那个谁。
④ 导演:一个场景栈
--------------------------------------------------------------------
场景栈一开始是空的
run(主菜单) -> 栈 ['主菜单']
push(设置) -> 栈 ['主菜单', '设置']
pop() -> 弹出 设置,栈 ['主菜单']
push(游戏) -> 栈 ['主菜单', '游戏']
replace(结算) -> 换掉 游戏,栈 ['主菜单', '结算']
pop() -> 弹出 结算,栈 ['主菜单']
栈空了,游戏结束
! 导演管的就这一个列表。run 压栈、pop 弹出、replace 换顶、push 叠加。
⑤ 什么时候用 push,什么时候用 replace
--------------------------------------------------------------------
| 方法 | 做什么 | 典型场景 |
|---|---|---|
| run | 清空重来,压一个新场景 | 启动 |
| push | 叠一层,下面那层还在 | 暂停菜单、设置、背包 |
| pop | 弹掉最上面那层 | 关掉暂停回游戏 |
| replace | 换掉最上面那层 | 菜单切到游戏、游戏切到结算 |
! push 和 replace 的区别就一句话:底下的场景还要不要留着。
第一段那个 479 是这篇最容易记混的数。
它不是「480 减 1 随便减的」,是「最后一格的下边缘」——格子从 0 数到 479,一共 480 格。
第二段一定要自己拿笔画一遍:100×80 的框,position 定在 (300, 200),锚点从 0 换到 1,
框整个往左下走了一个身位。锚点不改数字,只改解释方式。
第三段那两个数 (350, 240) 和 (260, 250) 是算出来的,不是抄的。
转 90 度之后水平和垂直的差值互换还要变号——这就是旋转矩阵在做的事,引擎里一行 C 代码,
这里四行 Python。
第四段跑完有个细节值得注意:replace 的做法是「弹出旧的、压入新的」,所以栈的深度不变。
深度不变,意味着返回键不会多退一层。
最后一个提醒:这一篇所有代码都不依赖 Cocos2d,是纯 Python 复刻的语义。
复刻的是语义,不是原库——真 cocos2d 里 director.push() 还带着转场动画等一堆东西。
30 秒实验
- 把
H从 480 改成 700,重跑,看换算表里那四个点的第二个数字怎么变。 - 把锚点改成 (0, 1)(左上角),重算 100×80 精灵的左下角——先算一遍再跑代码对答案。
- 在场景栈那四行调用里加一步
push('暂停')再pop(),看栈回到什么状态。
常见误区
- 「坐标系随便哪套,记住就行。」混着用必出错。 转换只在一处做,别这行翻那行不翻——差 480 的 bug 最费时间。
- 「锚点就是位置。」改锚点不动 position 一个数,但图会挪。 因为挪的是「position 对到图上的哪一点」,不是 position 本身。
- 「push 和 replace 差不多,能切过去就行。」差的是底下那层还在不在。 暂停用 replace,回来就得重开一局。
自测三问(点开对答案)
1. Pygame 的 (100, 50),在 640×480 的 Cocos2d 里是哪个点? —— (100, 429)。480 - 1 - 50 = 429。 不减 1 的话是 (100, 430),差别在于「坐标指格子左上角还是左下角」。 2. 锚点从 (0, 0) 改成 (1, 1),精灵在屏幕上往哪个方向挪? —— 往左下挪。锚点在左下角时 position 就是左下角; 锚点换成右上角后,position 变成右上角,整个图要往左挪一个宽、往下挪一个高。 **图的宽高是 (100, 80),所以就是左移 100、下移 80。** 3. 为什么暂停菜单要用 push 而不是 replace? —— 因为关掉暂停后要接着玩。 push 把暂停场景叠在游戏场景上面,游戏场景还在栈里,pop 一下就回到原样,分数、位置全都还在。 replace 会把游戏场景扔掉,回来只能重开。位置和流程都清楚了,下一篇钻进那棵树——场景、层、精灵到底怎么摞起来的。
- ☐ (本篇完,下一篇 4.3 节点、场景与层)