一张图铺满一屏,张数是「向上取整」算出来的,不是试出来的。 滚动不要一直加坐标,加完取模——不然几万帧之后精度就不够用。 参数集中放一张表,改一个数全项目生效;散着放就是「改一个忘一个」。
上一篇定完了结构和尺寸,这一篇填三样看起来琐碎、但每个项目都要写的东西。 教材 5.5 到 5.9 节:【背景】【菜单动画】【菜单】【参数设置】。 这三样合起来是「画面怎么铺、怎么动、数字放哪」。 平铺张数、偏移取模、速度曲线,全是真算出来的数。
一、一张图铺满一屏要几张
背景图的尺寸不会刚好等于窗口,所以要铺。铺几张?算,不要试。
横向要 ceil(窗口宽 / 图宽) 张,纵向要 ceil(窗口高 / 图高) 张。
演示里 480×800 的窗口、256×256 的图,算出来是横 2 张、纵 4 张,一共 8 张。
注意是向上取整,不是四舍五入。480 / 256 = 1.875,四舍五入是 2,向上取整也是 2,
这个例子看不出差别;但如果是 500 / 256 = 1.95,向上取整才是对的——差一点点也得再来一张。
演示还算了「最后一张被裁掉多少」:2 × 256 - 480 = 32 像素。 知道这个数,你就知道最右边那张只露出一半多一点。
二、滚动靠取模
背景要动,坐标就要一直加。但加着加着会出事。 演示跑了七帧,每帧偏移加 45:累计值一路涨到 270,但实际用的偏移是 14。 因为对 256 取了余数,画面看起来是连续的,数字却始终只有 0 到 255。 不取模会怎样?跑一小时 60 帧,坐标到二十多万。 数字本身不会溢出,但浮点数在这个量级上做加法,小数部分就开始丢精度,画面会一抖一抖地跳。
这条经验对任何「无限滚动」都成立:滚动地图、循环列表、轮播图,全是取模。
三、平铺从 -TILE 开始
偏移不为 0 的时候,最左边那张图只露出一部分,所以你得多铺一张在左边。 演示里偏移 87,横向的 x 起点算出来是 -169、87、343。 第一个是 -256 + 87,也就是「左边那张只露出 169 像素」。 画的时候从 -TILE 开始,一直画到超过窗口右边为止,多出来的部分让引擎裁掉。 裁掉不花钱,少画一块会让边上露白。
这也是「宁可多画一格」这条经验的来历。边界上的性能优化,永远留到最后再做。
四、菜单动画:滑进来
菜单最常见的效果是标题从屏幕外滑进来。做法就是给一个起始位置、一个结束位置、一个时长。 演示算的是从 y = -100 滑到 y = 500,用 1.5 秒、90 帧,每帧走 6.667 像素。 注意这个「每帧走多少」不是写死的,是算出来的。写死成 6 像素的话,改时长就得重算一遍; 用算式,改一个数全对。
滑完之后要停住。如果挂了 RepeatForever,它会一直来回滑; 挂 Sequence 加一个 DelayTime 再滑回来,就是「进去又出来」的过场。 同一个动作,挂法不同,效果完全两样。
五、参数集中成一张表
演示列了一张六项的配置表:格子大小 32、列 10、行 20、基础间隔 0.8 秒、每级衰减 0.85、 下限 0.05 秒。 没有这张表的时候,这些数字会以「32」「10」「20」的形式散落在十几个文件里。想改成 24 像素的格子? 你得全文搜索,然后一个个判断「这个 32 是不是格子大小」。 有这张表,改一个数,整个项目跟着变。这是所有项目都会走到的一步,区别只是「早做还是晚做」。
顺带一个好处:调手感的时候可以一次改一个参数跑一遍,不用改代码。 做游戏一半的时间在调手感,能不能快速调,直接决定效率。
六、速度曲线:越来越难的全部实现
下落间隔 = 基础间隔 × 衰减率的 (等级 - 1) 次方,再跟下限取最大值。 演示算了 1 到 10 级:0.800、0.680、0.578、0.491……一直到 0.185 秒。第 10 级比第 1 级快 4.3 倍。 这就是「越来越难」的全部实现——没有别的魔法,就是一个指数衰减。
为什么要设下限?因为间隔趋近 0 的时候,人根本反应不过来,游戏变成纯运气。 难度的上限应该是「手速极限」,不是「数学极限」。
七、动手 Demo:平铺、滚动取模与参数表
先算一张 256 的图铺满 480×800 要几张,最后一张裁掉多少。 再看滚动为什么必须取模:累计到 270,实际只用 14。 最后把参数列成表,算出 1 到 10 级的下落间隔。
import math
W, H = 480, 800
def info(s):
print(' ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
# ① 平铺要几张
sec('① 平铺要几张')
TILE = 256
nx = int(math.ceil(W / float(TILE)))
ny = int(math.ceil(H / float(TILE)))
info('窗口 %dx%d,图 %dx%d' % (W, H, TILE, TILE))
info('横 %d 张,纵 %d 张,共 %d 张' % (nx, ny, nx * ny))
info('最后一张被裁掉 %d 像素' % (nx * TILE - W))
# ② 滚动取模
sec('② 滚动靠取模')
step = 45
for i in range(7):
info('第 %d 帧 累计 %3d 实际偏移 %3d' % (i, i * step, (i * step) % TILE))
# ③ 平铺起点
sec('③ 平铺从 -TILE 开始')
off = 87
xs, x = [], -TILE + off % TILE
while x < W:
xs.append(x)
x += TILE
info('偏移 %d 时横向起点:%s' % (off, xs))
# ④ 菜单动画
sec('④ 菜单滑进来')
y0, y1, dur, fps = -100, 500, 1.5, 60
total = int(dur * fps)
info('%d 帧,每帧走 %.3f 像素' % (total, (y1 - y0) / float(total)))
for i in range(0, total + 1, total // 3):
info(' 第 %3d 帧 y = %.1f' % (i, y0 + (y1 - y0) * i / float(total)))
# ⑤ 速度曲线
sec('⑤ 速度随等级变')
base, decay, low = 0.8, 0.85, 0.05
for lv in range(1, 11):
gap = max(low, base * decay ** (lv - 1))
info('等级 %2d 间隔 %.3f 秒 每秒 %.1f 格' % (lv, gap, 1.0 / gap))
实跑输出:
--------------------------------------------------------------------
背景平铺、菜单动画与参数表
--------------------------------------------------------------------
① 一张图铺满一屏要几张
--------------------------------------------------------------------
一张 256×256 的图,铺 480×800 的窗口
横向要 2 张,纵向要 4 张,一共 8 张
最后一行最后一张有 32 像素被窗口裁掉
② 滚动的做法是取模,不是一直加
--------------------------------------------------------------------
每帧偏移加 45,累计值只保留除图宽之后的余数:
第 0 帧 累计 0 实际偏移 0
第 1 帧 累计 45 实际偏移 45
第 2 帧 累计 90 实际偏移 90
第 3 帧 累计 135 实际偏移 135
第 4 帧 累计 180 实际偏移 180
第 5 帧 累计 225 实际偏移 225
第 6 帧 累计 270 实际偏移 14
! 看第 6 帧:累计数是 270,实际偏移回到 14。
不取模的话坐标会一直长,几万帧之后浮点精度就不够用了。
③ 平铺的画法:从偏移开始,两个方向都铺
--------------------------------------------------------------------
偏移 87 时,横向贴图的 x 起点:[-169, 87, 343]
! 起点从 -TILE 开始,是因为偏移不为 0 时最左边那张只露出一部分。
④ 菜单动画:从屏幕外滑进来
--------------------------------------------------------------------
标题从 y = -100 滑到 y = 500,用 1.5 秒
90 帧,每帧走 6.667 像素
第 0 帧 y = -100.0
第 30 帧 y = 100.0
第 60 帧 y = 300.0
第 90 帧 y = 500.0
! 要注意的是「滑完还在不在原处」。挂 RepeatForever 就会一直来回滑。
⑤ 参数集中放一张表
--------------------------------------------------------------------
cell = 32
cols = 10
rows = 20
base = 0.8
decay = 0.85
min = 0.05
! 这些数写死在各个文件里,就是「改一个忘一个」的开始。
⑥ 速度随等级怎么变
--------------------------------------------------------------------
下落间隔 = base × decay 的 (等级 - 1) 次方,下限 0.05 秒
| 等级 | 下落间隔 | 每秒掉几格 |
|---:|---:|---:|
| 1 | 0.800 秒 | 1.2 格/秒 |
| 2 | 0.680 秒 | 1.5 格/秒 |
| 3 | 0.578 秒 | 1.7 格/秒 |
| 4 | 0.491 秒 | 2.0 格/秒 |
| 5 | 0.418 秒 | 2.4 格/秒 |
| 6 | 0.355 秒 | 2.8 格/秒 |
| 7 | 0.302 秒 | 3.3 格/秒 |
| 8 | 0.256 秒 | 3.9 格/秒 |
| 9 | 0.218 秒 | 4.6 格/秒 |
| 10 | 0.185 秒 | 5.4 格/秒 |
! 到第 10 级已经比第 1 级快 4.3 倍。这就是「越来越难」的全部实现。
第一段那两个 ceil 是这篇的核心。别用「试一下够不够」的办法定张数——换个窗口尺寸就得重试一遍。
第二段看第 6 帧就够了:累计 270、实际 14。
这个「绕回去」就是取模的全部意义。写成 if off > 256: off -= 256 也行,但 % 一行就够,
还不用担心步长比 256 大。
第三段那个 -169 有点反直觉。
它是「左边那张图露出多少」的负数形式:露出 87 像素,所以从 -169 开始画。
画到 87 正好接上第二张。
第四段那个 6.667 是算出来的。改成写死 6 像素的话,时长一改位置就对不上了。
凡是「按时间推进」的量,都用算式,别用常数。
第五段的 4.3 倍是这台机器上算出来的,不是什么标准值。
你换一套参数(比如衰减 0.9),难度曲线就是另一条。手感是要调的,不是抄的。
30 秒实验
- 把
TILE改成 128,重跑,看张数和裁掉的像素怎么变。 - 把
step改成 300,重跑,看偏移是不是一步就绕回头了。 - 把
decay从 0.85 改成 0.9,重跑,看第 10 级的间隔变成多少。
常见误区
- 「铺几张试试看,不够再加。」换个窗口尺寸就得再试一遍。用 ceil 算,一次到位。
- 「坐标一直加就行,屏幕会自己处理。」几万帧之后浮点精度掉光,画面开始跳。滚动一律取模。
- 「参数先写死,最后再抽出来。」最后永远不会来。第一天就集中放,成本最低。
自测三问(点开对答案)
1. 窗口 500 宽、图 256 宽,横着要铺几张? —— 两张。500 / 256 ≈ 1.95,向上取整是 2。 **注意不能四舍五入成 2 就以为对了——如果是 1.2,四舍五入会得到 1,那就铺不满。** 2. 为什么滚动偏移要取模,不取会怎样? —— 不取的话偏移会一直增大,一小时 60 帧就到二十多万。 浮点数在这个量级上做加法会丢小数位,画面会抖。取模之后偏移永远在 0 到 255 之间。 3. 下落间隔为什么要设一个下限? —— 因为指数衰减会无限接近 0,间隔太小玩家根本反应不过来,游戏变成纯运气。 **上限该由人的手速决定,不是由数学公式决定。**背景和菜单都动起来了,下一篇进游戏场景,把分数、等级和 HUD 挂上去。
- ☐ (本篇完,下一篇 5.3 游戏场景与 HUD)