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

精灵 = 一张图 + 一个位置 + 一个锚点,剩下的属性都是在它上面加料。 动作分三类:一瞬完成的、给定时间的、还有把前两类拼起来的。 动作按秒算不按帧算——这是它跟「每帧改坐标」最根本的差别。

📝 本章和前面几章的关系

上一篇把树搭起来了,这一篇给树上的东西挂内容:精灵、菜单、动作。 教材 4.5 节下面四小节:【Sprite】【Menu】【Action and Effect】【小结】。 这四节基本就是 Cocos2d 的「操作手册」:拿什么显示、怎么排按钮、怎么让它动。 动作时间轴、菜单排布、两种帧率下的位置,全是真算的。

一、精灵的六个属性

精灵就是「一张图 + 一个位置 + 一个锚点」,其余全是附加。

属性 意思
image 用哪张图,可以是文件,也可以是纯色块
position 位置,相对父节点
anchor 拿自身的哪个点去对 position
scale 缩放,1.0 是原大
rotation 旋转角度,正数是逆时针
opacity 不透明度,0 全透明、255 全实

锚点默认是 (0.5, 0.5),图的正中对着 position。这一条跟 Pygame 正好相反—— Pygame 贴图给的是左上角。 从 Pygame 转过来的第一个坑就在这儿:同样写 (300, 200),Pygame 里图在右边,Cocos2d 里图在正中间。 还有个写法上的差别:Cocos2d 的 scale 和 opacity 都是「属性」,挂个动作就能变; Pygame 里透明度得在贴之前 set_alpha,是一次性的动作,不是能持续变化的量。

二、菜单就是算出来的排布

Menu 不是一个神奇的控件,它就是「一堆按钮 + 一条排布规则」。 演示算了一遍竖排:四项目,从 y = 200 起,每项隔 45,位置依次是 200、155、110、65。 排布是算出来的,不是手填的。加一项,后面的位置自己往下让——手填的话你每次加按钮都要重算一遍。

横排也一样:定一个中心 320,间距 140,两项就落在 250 和 390。 公式是「中心减去半个间距,再按序号加整数个间距」。 自己写这套排布,十行代码就够。 知道它是算出来的,你就不怕「按钮位置不对」这种问题了——去看那两个数(起点、间距)就行。

三、动作有三类

类别 特点 有哪些
瞬时动作 当帧就完成,没有时长 Place、Hide、Show、CallFunc
间隔动作 给一个时长,慢慢变过去 MoveTo、RotateBy、ScaleTo、FadeIn
组合动作 把上面两类拼起来 Sequence、Spawn、Repeat、RepeatForever

名字里的 To 是「到某个值」,By 是「在这个基础上加多少」。 MoveTo((400, 200), 2) 是「走到 (400, 200)」;MoveBy((40, 0), 2) 是「往右挪 40」。 差一个字母,意思完全不同。同一个位置连挂两次 MoveTo 没有叠加效果,连挂两次 MoveBy 会走两倍距离。 瞬时动作里最有用的是 CallFunc——它让你在动作序列里插一段自己的函数。 「走到位置之后播一声响」就靠它,不用自己算时间。

四、Sequence 是一条时间轴

演示把四个动作接成一条 Sequence,并把每个的起止时间算了出来:MoveTo 占 0 到 2.0 秒, DelayTime 占 2.0 到 2.5 秒,RotateBy 占 2.5 到 3.5 秒,FadeOut 占 3.5 到 5.0 秒。 整条时间轴 5.0 秒。 排队的规则只有一条:上一个走完,下一个才开始,时间自动往后接。你不用写任何时间偏移, 引擎按顺序累加。 DelayTime 是个特别有用的动作——它什么都不做,就占一段时间。 「先亮两秒再灭」这种效果,没有它就得多写一个空动作。

自己实现 Sequence 也就十几行:记一个当前时长,每帧减 delta,减到 0 就切下一个。 引擎替你做的,是把边界情况(正好切换的那一帧、重复播放、被中途 stop)都处理干净了。

五、排着走还是同时走

Sequence 是排队,Spawn 是一起。同样两个 2 秒的动作:Sequence 一共 4 秒,Spawn 一共 2 秒。 要「边移动边转」用 Spawn,要「先移动再转」用 Sequence。这两个词就能描述 90% 的游戏动画。 更进一步是 Repeat(重复固定次数)和 RepeatForever(一直重复)。 背景的云一直飘、火把一直在闪,都是 RepeatForever。

组合是可以嵌套的:Spawn 里放两个 Sequence,Sequence 里放 Repeat。 但别嵌套超过三层,超过三层就该拆成几个命名好的动作,不然半年后你自己都读不懂。

六、动作按秒算,不按帧算

这一段是整章最该记住的一条。 演示拿一个「2 秒从 0 走到 300」的动作,在 30、60、120 三种帧率下各算了一遍。 总帧数分别是 60、120、240,但 0.5 秒时的位置都是 75.0,2.0 秒时的位置都是 300.0。 因为动作里存的是「时长」和「起点终点」,每帧只用「已经过去多少比例」算一次位置。 帧率变的是采样密度,不是轨迹。

对比一下手写帧计数:if frame % 6 == 0: x += 5,这种写法把速度和帧率绑死了。 换台机器跑 120 帧,游戏就快一倍。这就是为什么老游戏换电脑会「跑得太快」。 Pygame 里要避免这个问题,就得用 clock.tick() 的返回值按真实时间算, 或者直接按「每秒多少像素 × delta 秒」来更新。动作系统只是把这件事做进了引擎。

七、动作挂在节点上,一个节点能挂好几个

挂动作就一句 do(...),挂上去就不管了,每帧由动作管理器统一推进。 一个节点可以同时挂好几个互不相干的动作:一个管移动、一个管旋转、一个管闪烁。 它们各算各的,最后叠加到同一个节点上。 要停有 stop_all_actions(),要停某一个就把动作对象存下来单独停。 做「被打断的动画」时这个很关键——不 stop 掉旧动作,新动作会和旧动作打架。

这也是新手最常踩的坑:连续点两次「跳」,角色会跳两次高度。正确做法是挂新动作之前先 stop 掉旧的。

八、Effect 是另一回事

Effect 长得像动作(也是 do(...)),但它干的事完全不同:它不是改节点属性, 而是拿显卡去扭整块画面。 Waves3D 让画面像水面一样起伏,Twirl 让画面打旋,Lens3D 做出放大镜效果。 这些都要 OpenGL 的网格变形,纯软件绘图做不出来。 特效在本沙箱验证不了:它要真实的 OpenGL 上下文,而这里的 Cocos2d 连 import 都过不去。 所以这一节只有源码,没有实跑——这一条本课如实标出来。

实用建议:特效很吃性能,手机上尤其明显。先保证玩法跑满帧,再加特效。

九、动手 Demo:菜单排布、动作时间轴与帧率无关性

先算一遍竖排菜单和横排菜单的位置,看「排布是算出来的」是什么意思。 再把四个动作接成 Sequence,算出每个的起止秒。 最后用三种帧率跑同一个动作,验证同一时刻的位置一模一样。

W, H = 640, 480

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

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

# ① 竖排菜单
sec('① 竖排菜单:位置是算出来的')
y0, gap = 200, 45
for i, lab in enumerate(('开始游戏', '操作说明', '排行榜', '退出')):
    info('第 %d 项 %-4s y = %d' % (i + 1, lab, y0 - i * gap))

# ② 横排菜单
sec('② 横排菜单也是一条式子')
cx, gapx = W // 2, 140
for i, lab in enumerate(('继续', '重来')):
    info('%-4s x = %d' % (lab, cx - gapx // 2 + i * gapx))

# ③ Sequence 时间轴
sec('③ Sequence 是一条时间轴')
acts = [('MoveTo', 2.0), ('DelayTime', 0.5),
        ('RotateBy', 1.0), ('FadeOut', 1.5)]
t = 0.0
for name, dur in acts:
    info('%-9s 第 %.1f 秒 到 第 %.1f 秒,占 %.1f 秒'
         % (name, t, t + dur, dur))
    t += dur
info('整条时间轴一共 %.1f 秒' % t)

# ④ 帧率无关
sec('④ 动作按秒算,不按帧算')
x0, x1, dur = 0, 300, 2.0
def at(sec, fps):
    p = min(1.0, max(0.0, sec / dur))
    return x0 + (x1 - x0) * p
for fps in (30, 60, 120):
    info('%3d 帧率  共 %3d 帧  0.5 秒 x = %.1f  2.0 秒 x = %.1f'
         % (fps, int(dur * fps), at(0.5, fps), at(2.0, fps)))

实跑输出:

--------------------------------------------------------------------
精灵、菜单与动作:让它动起来
--------------------------------------------------------------------

  ① 精灵是「一张图 + 一个位置 + 一个锚点」
  --------------------------------------------------------------------

| 属性 | 意思 |
|---|---|
| image | 用哪张图,可以是文件,也可以是纯色块 |
| position | 位置,相对父节点 |
| anchor | 拿自身的哪个点去对 position |
| scale | 缩放,1.0 是原大 |
| rotation | 旋转角度,正数是逆时针 |
| opacity | 不透明度,0 全透明、255 全实 |

  锚点默认是 (0.5, 0.5),也就是图的正中对着 position。
  ! 跟 Pygame 不一样:Pygame 贴图给的是左上角,Cocos2d 默认给的是中心。

  ② 菜单就是一堆按钮,加一条排布规则
  --------------------------------------------------------------------
  Cocos2d 的 Menu 竖着排,每项之间隔一个固定距离。
  四项目,从 y = 200 起,每项隔 45:

| 第几项 | 文字 | y 坐标 |
|---:|---|---|
| 1 | 开始游戏 | 200 |
| 2 | 操作说明 | 155 |
| 3 | 排行榜 | 110 |
| 4 | 退出 | 65 |

  ! 排布是算出来的,不是手填的。加一项,后面的位置自己往下让。
  横排也一样:定一个中心,两项各偏半个间距。

| 文字 | x 坐标 |
|---|---:|
| 继续 | 250 |
| 重来 | 390 |

  中心 320,间距 140,所以两项落在 250 和 390。

  ③ 动作有一瞬的,也有要时间的
  --------------------------------------------------------------------

| 类别 | 特点 | 有哪些 |
|---|---|---|
| 瞬时动作 | 当帧就完成,没有时长 | Place、Hide、Show、CallFunc |
| 间隔动作 | 给一个时长,慢慢变过去 | MoveTo、RotateBy、ScaleTo、FadeIn |
| 组合动作 | 把上面两类拼起来 | Sequence、Spawn、Repeat、RepeatForever |

  ! 名字里的 To 是「到某个值」,By 是「在这个基础上加多少」。差一个字母,意思反。

  ④ Sequence 是一条时间轴
  --------------------------------------------------------------------
  把四个动作接成一个 Sequence:
    MoveTo    第 0.0 秒 到 第 2.0 秒,占 2.0 秒
    DelayTime 第 2.0 秒 到 第 2.5 秒,占 0.5 秒
    RotateBy  第 2.5 秒 到 第 3.5 秒,占 1.0 秒
    FadeOut   第 3.5 秒 到 第 5.0 秒,占 1.5 秒
  整条时间轴一共 5.0 秒
  ! Sequence 就是排队:上一个走完,下一个才开始,时间自动往后接。

  ⑤ 排着走还是同时走
  --------------------------------------------------------------------
  Sequence 是排队走,Spawn 是一起走。同样两个 2 秒的动作:
    Sequence(MoveTo, RotateBy)  一共 4.0 秒
    Spawn(MoveTo, RotateBy)     一共 2.0 秒
  ! 要「边移动边转」用 Spawn,要「先移动再转」用 Sequence。

  ⑥ 动作按秒算,不按帧算
  --------------------------------------------------------------------
  MoveTo 从 x = 0 走到 x = 300,耗时 2.0 秒。同一时刻,不同帧率:

| 帧率 | 总帧数 | 0.5 秒时 x | 2.0 秒时 x |
|---:|---:|---:|---:|
| 30 | 60 | 75.0 | 300.0 |
| 60 | 120 | 75.0 | 300.0 |
| 120 | 240 | 75.0 | 300.0 |

  帧率差四倍,同一时刻的位置一模一样——都是 75.0 和 300.0。
  ! 帧率高低只影响动作顺不顺,不影响动作走多久。
  手写「每 6 帧挪一次」那种计数法就做不到这点,改帧率动作就变快变慢。

  ⑦ 动作是挂在节点上的,一个节点可以同时挂好几个
  --------------------------------------------------------------------
    sprite.do(MoveTo((400, 300), 2))      走过去
    sprite.do(RotateBy(360, 2))           同时转一圈
    sprite.do(RepeatForever(blink))       一直闪
  ! 挂上去就不用管了,每帧由动作管理器统一推进。要停就 stop_all_actions()。

  ⑧ Effect 是另一回事
  --------------------------------------------------------------------

  [平台原样 · 本沙箱不执行]
  | from cocos.effect import Waves3D
  | self.do(Waves3D(waves=3, amplitude=20, duration=3))
  特效是拿显卡去扭整块画面,靠的是 OpenGL 的网格变形。

  ! 特效在本沙箱验证不了:它要真实的 OpenGL 上下文,而这里的 Cocos2d 连 import 都过不去。

这段代码把「排布、时间轴、帧率无关」三件事各拆成一小截,没有依赖 Cocos2d,纯 Python 就能跑。 数是对的,因为算的就是那几条式子。 第一段那两个数(200 和 45)就是全部的秘密。 按钮位置不对,永远是这两个数之一写错了,或者加了项忘了重算。 第三段那个 t 变量越加越大,这就是 Sequence 的全部实现。 真引擎里还多一层:每帧算「当前该由第几个动作负责」,以及切换那一帧的收尾。 第四段是全篇最值钱的一小段。三种帧率、同样两个时刻、位置一个像素都不差。 记住这个结论,你以后就不会写出「跳帧游戏就变快」的代码。 最后说清楚边界:Effect 那一节本课没跑,只贴了源码。 没跑过的就得标成没跑过,这是笔记可信度的底线。

30 秒实验

  1. 把竖排菜单的 gap 从 45 改成 60,重跑,看四项目的位置变成多少。
  2. 在 Sequence 里删掉那个 DelayTime,重算整条时间轴该多长,再跑对答案。
  3. 把 dur 改成 1.0,重跑,看 0.5 秒时的位置变成多少——想想为什么是一半的一半。

常见误区

  1. 「To 和 By 差不多,都是移动。」差远了。To 是到绝对值,By 是加相对量。 两个 MoveTo 连挂位置不变,两个 MoveBy 连挂距离翻倍。
  2. 「动作挂上去之后想改就再挂一个。」新旧动作会同时跑,互相抢同一个属性。要先 stop 掉旧的。
  3. 「帧率越高动作越快。」不会。动作按秒算,帧率只影响平不平滑。 会变快的只有「每帧固定加多少」那种手写计数。
自测三问(点开对答案) 1. `MoveTo((400, 200), 2)` 和 `MoveBy((40, 0), 2)` 有什么区别? —— MoveTo 是走到绝对位置 (400, 200);MoveBy 是在当前位置上再挪 40 像素。 连挂两次 MoveTo 还是停在 (400, 200),连挂两次 MoveBy 会一共挪 80 像素。 2. Sequence 里放 MoveTo(2 秒) 和 RotateBy(1 秒),整条多长?换成 Spawn 呢? —— Sequence 是排队,2 + 1 = 3 秒。Spawn 是同时,取长的那个,2 秒。 **一个加起来,一个取最大值,这是两者的本质区别。** 3. 为什么帧率从 30 提到 120,同一个动作的终点位置和时间都不变? —— 因为动作内部存的是时长和起止值,每帧只用「已过时间 / 总时长」算进度。 帧率提高只是采样点变密,轨迹一模一样。**变化的是平滑度,不是速度。**

到这儿 Cocos2d 的基础件都认完了。 下一篇换一本教材里的第二个项目:俄罗斯方块,看这些件怎么拼成一个能玩的游戏。

相关笔记