自定义动作就一个套路:存时长,每帧推进已过时间,按进度算出属性值。 那个「已过时间」必须封顶到时长,不封顶最后一帧会冲过头。 事件是「登记一下、有事通知我」,发的人不用知道谁在听。
前两篇讲的是「世界怎么存、东西怎么撞」,这一篇讲「怎么让它自己动、怎么让它互相通知」。 教材 6.4、6.5 节:【自定义 Action】【自定义 Event】。 这两样是引擎留给你自己填的空:动作定义「怎么变」,事件定义「谁知道」。 正弦动作的每一帧坐标、事件的回应数,都是真跑出来的。
一、动作的套路就那一套
上一章讲过引擎自带的动作:给个时长,每帧算一次进度,按进度改属性。 自定义动作照抄这个套路就行: 存起点、终点、时长;每帧推进「已过时间」;用「已过时间 ÷ 时长」当进度算出当前值。 三行状态、一行公式,剩下的都是包装。引擎的动作系统再花哨,内核就是这一句。
二、写一个正弦起伏的动作
演示写了一个「一边往右走、一边上下摆」的动作:x 线性增加,y 按正弦变化。
公式是 y = sin(进度 × 2π) × 振幅。正弦函数吃的是 0 到 2π,正好对应进度 0 到 1。
跑出来 11 个采样点:x 从 0 一路到 300,y 从 0 涨到 +38 再回 0、到 -38、回到 0。
一个完整的正弦周期,正好走完全程。
想让摆得更快?把 2π 换成 4π,一个周期就压缩成半程。这个乘数是「几个来回」的意思。
想想游戏里哪些效果是这么做的:飘浮的道具、呼吸的提示按钮、水面的浮动。
都是一个正弦,只是振幅和周期不同。
三、封顶那一行不能省
演示里那个 elapsed 每帧加上 dt,但加完要跟时长取个最小值。
不封顶会怎样?最后一帧加上 dt 会超过时长,进度变成 1.02,位置就冲出终点一点点。
一帧 16 毫秒,位置冲出去几个像素。
看似小,但动作结束的时候会「抖一下」。
更严重的是:如果这个动作是按顺序排的(Sequence),下一段会从错的位置开始。
演示里跑到第 20 帧,elapsed 是 2.00、done 是 True;再推一帧,elapsed 还是 2.00。
这就是封顶的效果。
四、事件:登记一下,有事通知我
事件机制就三个动作:登记(谁想听什么)、发出(发生了什么)、分发(挨个通知)。 演示里登记了三个处理函数,两个关心「吃到老鼠」,一个关心「撞墙」。 发一次「吃到老鼠」,收到了 2 个回应。 发一个没人听的事件呢? 收到 0 个回应,也不报错。这一点很重要——意味着你可以先发事件,晚上再加处理函数, 不用改发的那边。
这就是解耦的好处。发的人不用知道谁在听,听的人不用知道谁发的。 加一个新功能,只要登记一个新的处理函数。
五、轮询和通知,什么时候用哪个
| 对比项 | 轮询 | 通知 |
|---|---|---|
| 做法 | 每帧挨个问「你变了吗」 | 变了主动喊一声 |
| 开销 | 每帧都要问,哪怕没变 | 只有变了才花时间 |
| 写起来 | 简单直接 | 要先搭一层登记机制 |
| 适合 | 状态少、每帧都要用 | 状态多、变化少、关心的人多 |
键盘输入适合轮询:每帧都要知道按键状态,而且状态本来就少。 游戏事件适合通知:吃到东西、角色死亡、过关。这些事「发生一次」,不是「持续存在」。 分不清的时候问自己一句:这个东西是「一直是某个值」,还是「某一刻发生了一件事」?前者轮询, 后者通知。
六、两个坑
坑一:在处理函数里再发同一个事件,会无限递归,直到栈溢出。 典型场景是「分数变了就播动画」,而播动画又改了分数。 坑二:分发过程中有对象死了、被删了,后面的处理函数还在跑,会拿到空引用或者旧数据。 两个坑的解法是同一个:别在分发过程中改结构。把要改的记下来,等分发完了统一处理。
这跟数据库的「不要在一个事务里改表结构」是一个道理。 凡是「遍历 + 修改」并行的地方,都要留这一步。
七、动手 Demo:自己写一个动作、自己搭一个事件总线
先写一个「边走边摆」的动作,把每一帧的坐标打出来。 再看封顶那一行去掉会怎样。 最后搭一个最小的事件总线,试试两个人同时关心同一件事。
import math
class SineMove:
def __init__(self, x0, x1, amp, duration):
self.x0, self.x1, self.amp, self.duration = x0, x1, amp, duration
self.elapsed = 0.0
@property
def done(self):
return self.elapsed >= self.duration
def step(self, dt):
self.elapsed = min(self.duration, self.elapsed + dt) # 封顶
t = self.elapsed / self.duration
return (self.x0 + (self.x1 - self.x0) * t,
math.sin(t * math.pi * 2) * self.amp)
class Bus:
def __init__(self):
self.handlers = {}
def on(self, name, fn):
self.handlers.setdefault(name, []).append(fn)
def emit(self, name, *args):
return [fn(*args) for fn in self.handlers.get(name, [])]
def info(s):
print(' ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
# ① 跑 21 帧
sec('① 一帧一帧走')
act = SineMove(0, 300, 40, 2.0)
for i in range(21):
if i:
act.step(1 / 10.0)
t = act.elapsed / act.duration
info('第 %2d 帧 x = %5.1f y = %+5.1f'
% (i, act.x0 + (act.x1 - act.x0) * t,
math.sin(t * math.pi * 2) * act.amp))
# ② 封顶
sec('② 封顶之后不再变')
info('第 20 帧 elapsed = %.2f,done = %s' % (act.elapsed, act.done))
act.step(1 / 10.0)
info('再推一帧 elapsed = %.2f' % act.elapsed)
# ③ 事件
sec('③ 登记、发出、分发')
bus = Bus()
bus.on('吃到老鼠', lambda n: '+%d 分' % n)
bus.on('吃到老鼠', lambda n: '响一声')
bus.on('撞墙', lambda: '停下')
info('吃到老鼠 -> %s' % bus.emit('吃到老鼠', 100))
info('撞墙 -> %s' % bus.emit('撞墙'))
info('没人听的事件 -> %s' % bus.emit('没人听这个'))
实跑输出:
--------------------------------------------------------------------
自定义动作、事件与「谁通知谁」
--------------------------------------------------------------------
① 自定义动作就是「每帧改一点」
--------------------------------------------------------------------
引擎里的动作都是同一个套路:给个时长,每帧算一次进度,按进度改属性。
自己写一个也照这个来:存起点终点和时长,每帧推进 elapsed,算出当前值。
! 注意那个 elapsed 一定得封顶到 duration——不封顶的话最后一帧会冲过头。
② 真跑一个正弦起伏的动作
--------------------------------------------------------------------
从 x = 0 走到 x = 300,用 2 秒,顺带上下摆 40 像素
| 帧 | 已过秒数 | x | y 偏移 |
|---:|---:|---:|---:|
| 0 | 0.00 | 0.0 | +0.0 |
| 2 | 0.20 | 30.0 | +23.5 |
| 4 | 0.40 | 60.0 | +38.0 |
| 6 | 0.60 | 90.0 | +38.0 |
| 8 | 0.80 | 120.0 | +23.5 |
| 10 | 1.00 | 150.0 | +0.0 |
| 12 | 1.20 | 180.0 | -23.5 |
| 14 | 1.40 | 210.0 | -38.0 |
| 16 | 1.60 | 240.0 | -38.0 |
| 18 | 1.80 | 270.0 | -23.5 |
| 20 | 2.00 | 300.0 | +0.0 |
! x 一路加,y 走了一个完整的正弦波——一个正弦周期,正好用完全程。
③ 动作结束之后
--------------------------------------------------------------------
走到第 20 帧,elapsed = 2.00,done = True
再推一帧,elapsed 还是 2.00(封顶了)
! 封顶之后位置不再变。不封顶的话它会一直往前跑,飞出屏幕。
④ 事件:登记一下,有事通知我
--------------------------------------------------------------------
登记了三个处理函数,其中两个关心「吃到老鼠」
发出「吃到老鼠」事件,收到 2 个回应:['+100', '响']
顺带产生的副作用:['分数 +100', '播一声']
发出「撞墙」事件,收到:['停']
发出一个没人听的事件,收到 0 个回应,也不报错
! 发的人不用知道谁在听,听的人不用知道谁发的。这就是「解耦」。
⑤ 轮询和通知,什么时候用哪个
--------------------------------------------------------------------
| | 轮询 | 通知 |
|---|---|---|
| 做法 | 每帧挨个问「你变了吗」 | 变了主动喊一声 |
| 开销 | 每帧都要问,哪怕没变 | 只有变了才花时间 |
| 写起来 | 简单直接 | 要先搭一层登记机制 |
| 适合 | 状态少、每帧都要用 | 状态多、变化少、关心的人多 |
! 键盘输入适合轮询,游戏事件(吃到、死亡、过关)适合通知。
⑥ 两个坑
--------------------------------------------------------------------
坑一:在处理函数里再发同一个事件,会无限递归。
坑二:处理函数里删掉了别人(比如对象死了),后面的函数还在跑,会拿到空引用。
! 两个坑都能用「先收集改动,事件分发完再统一处理」绕过去。
第一段那 21 帧里,x 是均匀的、y 是正弦的。
同一份进度,喂给两个不同的公式,出来两种运动。这就是动作系统的全部秘密。
第二段那句 min(self.duration, self.elapsed + dt) 是这一篇最该抄下来的一行。
少写它,动作结束时会抖一下,而且后面接的动作会从错的位置开始。
第三段那个 lambda n: '+%d 分' % n 只是个占位的处理函数。
真项目里它会去改分数、播声音、弹提示——而发事件的那边一行都不用改。
注意 emit 返回的是一个列表:所有处理函数的返回值都收起来了。真引擎里通常不需要返回值,
这里返回是为了让输出看得见「通知了几个人」。
这段代码不依赖 Cocos2d。事件总线在任何语言、任何框架里都是这个形状,认一次,到处都能用。
30 秒实验
- 把
math.sin(t * math.pi * 2)里的 2 改成 4,重跑第一段,看波形变成几个来回。 - 把封顶那行的
min(...)改成直接相加,重跑第二段,看 elapsed 最后是多少。 - 给
bus再登记一个「吃到老鼠」的处理函数,看回应从几个变成几个。
常见误区
- 「动作就是每帧手动改坐标。」手动改也行,但你要自己管结束判定、自己管打断。 封装成动作,这些麻烦只写一次。
- 「封顶那行无所谓,差一点点看不出来。 」单看一个动作确实不明显,但接着别的动作时,误差会累积。
- 「事件不如直接调用函数简单。」两三个对象的时候确实简单。 十个对象互相通知的时候,直接调用会变成一张蜘蛛网。
自测三问(点开对答案)
1. 自定义动作要存哪三样东西?核心公式是什么? —— 起点(或起点终点)、时长、已过时间。核心公式是「进度 = 已过时间 ÷ 时长」,再按进度算属性值。 **所有动作都是这一套。** 2. 为什么「已过时间」要跟时长取最小值? —— 因为每帧加的是一个固定步长,最后一帧加上去会超过时长,进度大于 1,位置冲出终点。 **表现是动作结束时抖一下,接下一个动作时位置也错了。** 3. 发出一个没人听的事件,应该报错吗? —— 不该。收到 0 个回应、什么都不做是对的。**这样你才能先埋事件、后加处理函数,两边互不影响。**动作和事件都有了,下一篇讲画面怎么动起来:一张大图切成小图,按时间换着播。
- ☐ (本篇完,下一篇 6.4 图片集与帧动画)