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

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 秒实验

  1. 把 H 从 480 改成 700,重跑,看换算表里那四个点的第二个数字怎么变。
  2. 把锚点改成 (0, 1)(左上角),重算 100×80 精灵的左下角——先算一遍再跑代码对答案。
  3. 在场景栈那四行调用里加一步 push('暂停') 再 pop(),看栈回到什么状态。

常见误区

  1. 「坐标系随便哪套,记住就行。」混着用必出错。 转换只在一处做,别这行翻那行不翻——差 480 的 bug 最费时间。
  2. 「锚点就是位置。」改锚点不动 position 一个数,但图会挪。 因为挪的是「position 对到图上的哪一点」,不是 position 本身。
  3. 「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 会把游戏场景扔掉,回来只能重开。

位置和流程都清楚了,下一篇钻进那棵树——场景、层、精灵到底怎么摞起来的。

相关笔记