整关是一张字符表,改关卡不用改代码。 摆图就是把表遍历一遍,每个非空格子按 (列×32, 行×32) 贴一张图。 闪烁跟帧动画内核是同一个:时间取模,按余数选图。
上一篇把界面摆完了,这一篇进游戏本身:关卡怎么存、怎么摆、怎么动。 教材 7.9 到 7.12 节:【编辑地图】【游戏场景】【闪烁砖块】【图片分割与动画效果】。 四节合起来是「数据 → 画面」这条链路:表怎么来、怎么变成图、图怎么变。 本篇的关卡表、对象计数、闪烁时序、视口范围,都是真跑出来的。
一、地图就是一张字符表
演示里那八行字符串就是一段关卡。点表示空气、G 是地面、B 是砖块、C 是金币、E 是敌人出生点。 一张表说清整个关卡。改关卡不用改一行代码——这就是「数据驱动」。 真项目里这张表是 Tiled 画出来、导出成 JSON 的。但导出来还是这个形状:一张二维表,每格一个编号。 字符还是数字不重要,重要的是「它是一张表」。
为什么用字符演示?因为你能一眼看出关卡长什么样。用数字就得对着图集一格格查。 调关卡的时候,先用字符画草图,再转成数字,快得多。 注意表里那一段连续的 G 中间有个缺口——那就是坑。地图数据里的「坑」不是特殊对象, 就是「这里没有地面」。
二、摆图:一次遍历
for 每一行: for 每一格: 如果是非空格子,就在 (列×32, 行×32) 贴一张图。摆图就这么点代码。
演示算了几个位置:(0, 6) 是地面,像素 (0, 192);(9, 3) 是砖块,像素 (288, 96);
(10, 2) 是金币,像素 (320, 64)。
(32, 7) 那格是空气,位置是 (1024, 224)——空气也有位置,只是不贴图。写代码的时候,
「跳过」这一步要放在算位置之前还是之后,都行,但别忘。
遍历一遍整关要贴多少张? 演示数了一段:地面 88 个、砖块 7 个、金币 2 个、敌人点 2 个,一共 99 个对象。 这个数不是好玩的,是性能预算。每个对象都要参与碰撞判定,整关两千多格, 两两比就是几百万次——这就是上一篇讲的「只判屏幕附近」的来历。
三、闪烁砖块:按时间换图
经典马里奥里那种一闪一闪的砖块,做法比想象的简单:两张图来回切。
一个周期 1 秒,前 0.5 秒显示 A 图,后 0.5 秒显示 B 图。
算式是 阶段 = 已过秒数 % 1.0,然后 阶段 < 0.5 就显示 A。
演示跑了 8 个时间点,1.00 秒时阶段回到 0.00,又显示 A。
又是取模。跟背景滚动、帧动画一模一样的套路:时间对周期取余,再按余数分段。 这个套路能复用到所有「周期性变化」的东西上:呼吸的按钮、闪红的血条、上下浮动的道具。 认一次,到处用。
四、闪烁和帧动画的区别
| 对比项 | 帧动画 | 闪烁 |
|---|---|---|
| 循环内容 | 一串连续的图(8 帧走路) | 两张图来回切 |
| 周期 | 每帧 1/12 秒 | 每半秒换一次 |
| 驱动 | 角色状态(在走才播) | 时间,跟角色状态无关 |
内核是同一个:时间取模,按结果选图。区别只在「一段有几张、每张多久」。 驱动源不一样这点值得说:走路动画是「有输入才播」,停下来要切回站立图; 闪烁是「不管你在干嘛,我就一直闪」。一个是状态驱动,一个是时间驱动。 分清这两类,你加动画的时候就知道该挂在哪:状态驱动的挂在角色逻辑里,时间驱动的挂在场景更新里。 挂错地方,角色死了动画还在闪。
五、图片分割
砖块、金币、角色,全都从图片集里切。切法第六篇算过:一个除法一个取余。 这里多一步:同一类对象共用一张图。所有砖块贴的是同一张,所有金币也是。 不用每个对象存一份图,只存「我用图集里的第几块」。 这是省内存的关键。一百个砖块如果各存一份 32×32 的图,就是一百万像素;共用一张,只有一千像素。 差三个数量级。
六、镜头决定画哪几列
演示里镜头在 x = 400,窗口 800 宽,看得见第 12 到 37 列,剩下 23 列不用画。 跟上一章瓦片地图那套算法完全一致。同一个道理,用在了第二个项目上。 注意「不用画」不只是省绘制,还省了「遍历」。地图越宽,这个比例越夸张。
还有一个细节:镜头移动的时候,新进入视野的那一列要「补画」。 做法就是每帧按新的视口重算一遍范围,不要试图只画「新进来的那一列」——那种增量更新省不了多少, 还容易漏。
七、动手 Demo:关卡表、摆图与闪烁砖块
先看一张字符表怎么描述一段关卡,再把格子坐标换成像素坐标。 然后数一数这一段里有多少对象,想想整关两千格是什么量级。 最后算闪烁砖块的时序,看两张图怎么来回切。
TILE, W = 32, 800
LEVEL = [
'................................................',
'................................................',
'..........C.....................................',
'.........BBB....................C...............',
'.............E..........BBBB....................',
'....................E...........................',
'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.....GGGGGGGGGGGGG',
'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.....GGGGGGGGGGGGG',
]
NAME = {'.': '空气', 'G': '地面', 'B': '砖块', 'C': '金币', 'E': '敌人点'}
def info(s):
print(' ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
# ① 摆图
sec('① 格子 -> 像素')
for col, row in ((0, 6), (9, 3), (10, 2), (13, 4), (32, 7)):
info('(%2d, %d) %s -> 像素 (%d, %d)'
% (col, row, NAME[LEVEL[row][col]], col * TILE, row * TILE))
# ② 数对象
sec('② 数一数有什么')
cnt = {}
for line in LEVEL:
for ch in line:
if ch != '.':
cnt[ch] = cnt.get(ch, 0) + 1
for ch in ('G', 'B', 'C', 'E'):
info('%s 共 %d 个' % (NAME[ch], cnt.get(ch, 0)))
info('合计 %d 个对象' % sum(cnt.values()))
# ③ 闪烁
sec('③ 闪烁砖块:时间取模')
for t in (0.0, 0.3, 0.5, 0.7, 1.0, 1.5):
phase = t % 1.0
info('经过 %.1f 秒,周期内 %.2f -> 显示 %s'
% (t, phase, 'A' if phase < 0.5 else 'B'))
# ④ 视口
sec('④ 镜头看得见几列')
cam = 400
info('镜头 %d,窗口 %d -> 第 %d 到 %d 列'
% (cam, W, cam // TILE, (cam + W - 1) // TILE))
info('整关 %d 列,差 %d 列不用画'
% (len(LEVEL[0]), len(LEVEL[0]) - W // TILE))
实跑输出:
--------------------------------------------------------------------
地图、场景与闪烁砖块
--------------------------------------------------------------------
① 地图是一张字符表
--------------------------------------------------------------------
关卡宽 48 格、高 8 格(这里只截一段看)
第 0 行 ................................................
第 1 行 ................................................
第 2 行 ..........C......................................
第 3 行 .........BBB....................C................
第 4 行 .............E..........BBBB.....................
第 5 行 ....................E............................
第 6 行 GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.....GGGGGGGGGGGGG
第 7 行 GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.....GGGGGGGGGGGGG
| 字符 | 是什么 | 能不能站 |
|---|---|---|
| . | 空气 | 不能 |
| G | 地面 | 能 |
| B | 砖块 | 能 |
| C | 金币 | 不能 |
| E | 敌人出生点 | 不能 |
! 一张表说清整个关卡。这就是「数据驱动」——改关卡不用改代码。
② 按数据摆图
--------------------------------------------------------------------
第 (col, row) 格的像素位置 = (col × 32, row × 32)
| 格子 | 里面是什么 | 像素左上角 |
|---|---|---|
| (0, 6) | 地面 | (0, 192) |
| (9, 3) | 砖块 | (288, 96) |
| (10, 2) | 金币 | (320, 64) |
| (13, 4) | 敌人点 | (416, 128) |
| (32, 7) | 空气 | (1024, 224) |
! 摆图就是把这张表遍历一遍,遇到非空格的格子就贴一张图。两百行代码都不用。
③ 数一数关卡里有什么
--------------------------------------------------------------------
G 共 88 个
B 共 7 个
C 共 2 个
E 共 2 个
这个片段一共 99 个对象,整关两千多格全是这样摆出来的。
! 数这个不是为了好玩——它是性能预算。
每个对象都要参与碰撞判定,数量直接决定你要不要做「只判屏幕附近」的优化。
④ 闪烁砖块:按时间换图
--------------------------------------------------------------------
一个闪烁周期 1.0 秒:前 0.5 秒显示 A 图,后 0.5 秒显示 B 图
| 经过秒数 | 周期内位置 | 显示哪张 |
|---:|---:|---|
| 0.00 | 0.00 | A |
| 0.30 | 0.30 | A |
| 0.49 | 0.49 | A |
| 0.50 | 0.50 | B |
| 0.70 | 0.70 | B |
| 0.99 | 0.99 | B |
| 1.00 | 0.00 | A |
| 1.50 | 0.50 | B |
! 又是取模。跟背景滚动、帧动画一模一样的套路——时间对周期取余,再按余数分段。
⑤ 闪烁和帧动画的区别
--------------------------------------------------------------------
| | 帧动画 | 闪烁 |
|---|---|---|
| 循环内容 | 一串连续的图(8 帧走路) | 两张图来回切 |
| 周期 | 每帧 1/12 秒 | 每半秒换一次 |
| 驱动 | 角色状态(在走才播) | 时间,跟角色状态无关 |
! 两者内核是同一个:时间取模,按结果选图。区别只在「一段有几张、每张多久」。
⑥ 地图上哪个位置该被镜头看见
--------------------------------------------------------------------
镜头在 x = 400,窗口宽 800
看得见的列:12 到 37
这一段有 25 列是看得见的,剩下 23 列不用画
! 跟上一章瓦片地图那套算法完全一致。同一个道理,用在了第二个项目上。
第一段那个 (col * TILE, row * TILE) 是这一篇唯一需要记的算式。
所有摆图、所有切图、所有碰撞,都建立在这一个乘法上。
第二段数出来 99 个对象,整关两千多格就是几十倍。
这就是为什么碰撞要「只判屏幕附近」。数一遍心里有底,比拍到卡了再去找原因强。
第三段那个 t % 1.0 跟背景滚动、帧动画是同一个套路。
周期性变化一律「时间取模 + 分段」,这是通用解法。
第四段那个 25 列跟上一章瓦片地图算的一模一样。
同一个道理用在第二个项目上,说明它真的是「道理」而不是「某个引擎的特性」。
这段代码纯 Python 能跑。地图数据跟引擎无关——换成任何引擎,这张表还是这张表。
30 秒实验
- 把
TILE从 32 改成 16,重跑第一段,看像素坐标全变成多少。 - 在
LEVEL里再加一行全是G,重跑第二段,看地面数量涨多少。 - 把闪烁周期从 1.0 改成 2.0,重跑第三段,看 1.5 秒时显示哪张。
常见误区
- 「关卡直接写在代码里,用 if 判断哪里是墙。」加一关就要改代码。用表,加关卡只是加一张表。
- 「每个砖块存一份自己的图。」一百个砖块就是一百份图。共用图集,只存编号。
- 「闪烁是特殊效果,得单独写一套。」它就是「时间取模 + 两张图」。 跟帧动画同一套代码,只是图少。
自测三问(点开对答案)
1. 关卡表里 (9, 1) 那格是砖块,它的像素左上角在哪? —— (288, 32)。9 × 32 = 288,1 × 32 = 32。**就一个乘法。** 2. 为什么地图要做成数据表,不直接写代码? —— 因为改关卡不用改代码。加一关只是加一张表,不用动逻辑。**这就是「数据驱动」的意义。** 3. 闪烁砖块和走路动画,内核上有什么相同?驱动源有什么不同? —— 相同:都是时间取模、按余数选图。 不同:走路动画由角色状态驱动(在走才播),闪烁由时间驱动(一直在闪)。 **挂错地方会出现「角色死了还在闪」。**地图有了,下一篇摆角色——一个基类、八种派生,各管各的规则。
- ☐ (本篇完,下一篇 7.4 八种角色与它们的移动)