跳到主要内容

平台游戏 — 一帧之内发生了什么

这一章讲三件事: 一张字符画怎么变成可玩的关卡; 按下一次方向键之后的一个时间步里,程序按什么顺序算了哪些数(本章主走查); 画面怎么跟上逻辑(DOM 显示层、镜头滚动、100 毫秒的帧上限)。 读完你能回答:这款游戏的「世界状态」是哪几个值、一帧从输入到画面经过哪几步。

1. 先看现象:故意做得极简

作者自述对计算机的最初迷恋来自游戏,但随即补了一句忠告:「我不祝任何人以游戏编程为业」——想入行的人数与真实需求严重失衡,行业环境不健康;不过写着玩很有趣1

游戏原型是 Thomas Palef 的 Dark Blue,选它就为极简:画面里没有贴图也能玩——玩家是一个黑方块,金币是黄方块,岩浆是红方块;跳上跳下,金币收齐过关2

两个设计决定先立住:

  • 手感优先于真实:跳跃可以在空中变向,这不物理,但「给玩家一种直接操纵屏幕化身的掌控感」3;
  • 世界分两层:背景是死网格(空/墙/岩浆三种格),活动元素(玩家、金币、会动的岩浆)叠加其上,坐标允许小数——所以移动是平滑的,不像背景那样一格一格跳4

顺带一提承载层的选择:这章用 DOM 画游戏。作者自己都有点意外地承认:DOM 并非为高性能图形设计,实际表现却比预期好——现代机器上这种规模的小游戏根本不用操心优化5

2. 关卡:一张字符画

关卡用给人读、给人改的字符串定义:. 是空地,# 是墙,+ 是岩浆,@ 是玩家出生点,o 是金币;=|v 是三种会动的岩浆(横漂、竖漂、下滴)6。规则两条:收齐金币过关;碰到岩浆,本关恢复原样重来7

解析器的产物分两半:背景是「字符的二维数组」(每格记 empty/wall/lava),活动元素收进一个数组。实现上很巧:levelChars 这张表,把每个字符映射到「背景类型字符串」「一个带 static create 方法的类」——同一个表,两种产物8。玩家和金币的位置用二维向量(把 x、y 两个数捆成一个值)Vec——加法、缩放两个方法;times 的用途马上会在主走查里出现:拿速度乘时间得位移)9。书里的示例关卡解析出来是 22 × 910

3. 状态:又是「算新状态,不改旧状态」

跑动中的游戏状态是一个 State 类:关卡(不变)+ 活动元素数组 + status(playing/lost/won)11。它的 update 方法每次都造一个新的 State,旧的原样留着——第 07 章送货机器人的那套持久数据纪律,原样搬进了游戏12

每个活动元素遵守同一个接口:size(尺寸)、pos(左上角坐标)、update(算一步之后的新自己)、type(画什么颜色的字符串)13;各类再有 static create 供关卡解析时调用。Player 的出生点有个小细节:玩家高 1.5 格,而 @ 只标一格,所以 create 里把出生点上移半格——让脚底贴住字符所在格的地面14。尺寸(0.8 × 1.5)对每个玩家都一样,于是它放原型上而不是放 getter——getter 每次读都会新建一个 Vec,白白制造垃圾;字符串不可变,所以 type 用 getter 没关系15

4. 主走查:一个时间步之内

游戏循环把时间切成小步,步长以秒计,位移 = 速度 × 步长16。下面拿一个具体步走完全程。步长 0.05 秒与各坐标是为演示编的;所有常数(横向速度 7、重力 30、起跳速度 17)是书里给出的17

局面 玩家 pos=(3, 5),size=0.8×1.5,speed=(0, 0.5)(上一帧遗留:正在下坠)
按键 {ArrowRight: 按住, ArrowUp: 按住};脚下 0.1 格处有一排墙

X 轴(横竖两轴分开算——撞地不该挡住横移,撞墙不该停住坠落[^18]):
xSpeed = 7(右键按住)
试探新位置 (3 + 7×0.05, 5) = (3.35, 5)
touches 问:这块矩形压到墙了吗?→ 没有 → 采纳
(若撞墙:整个横向位移直接丢弃,不是「贴墙滑动」)

Y 轴:
ySpeed = 0.5 + 0.05 × 30 = 2.0 ← 重力先加速度
试探新位置 (3.35, 5 + 2.0×0.05) = (3.35, 5.1)
touches → 压到地了!二选一:
按着上键 且 正在下坠(ySpeed > 0)→ ySpeed = -17(起跳!)[^19]
否则 → ySpeed = 0(撞头/落地,竖直速度清零)
位置保持 (3.35, 5)——穿进墙的那段位移被整个取消

产出 new Player((3.35, 5), (7, -17)) ← 又是一个新对象,旧的没动

touches 的判法配得上多看一眼:矩形坐标多半是小数,floor 左上、ceil 右下,把矩形罩住的格子范围求出来,逐格查类型;落在关卡外的格子一律算墙——一石二鸟:玩家出不了世界,数组也读不越界18

这一步之后 State.update 还有两道工序:玩家压到岩浆 → status 变 lost;与其他 actor 重叠(两轴投影都相交才算)→ 调它的 collide——金币从数组里滤掉自己,滤完一个不剩就 status 变 won;岩浆 actor 则直接判负19。至于会动的岩浆:横漂竖漂的碰壁后速度取反(× −1)弹回,下滴的带着 reset 记忆跳回出生点再掉20;金币没有碰撞,只用 sin 波做上下浮动,每个金币的初相位随机取 0 到 2π——不然全场金币齐跳广场舞21

这套碰撞的诚实边界:进墙就取消整个动作,意味着时间步必须小——步子太大,玩家会悬空在离地肉眼可见的地方。更好的做法(求出精确碰撞点、贴上去)更难;本书选择用小步长把问题藏起来22。这是全书少见的「明说选择了糙办法」的时刻,值一条判断:

判断(我们的,不是书里的): 「取消整个动作」这类笨碰撞之所以可行,靠的是后面 runAnimation 把单帧步长钳到 100 毫秒以内——糙办法+护栏,好过精办法+无护栏。 如果错,会错在: 若卡顿真的把一帧拉到 100 毫秒上限,位移 = 7 × 0.1 = 0.7 格,仍小于玩家 0.8 格的宽度,护栏恰好够用;若哪天把移速改得比「宽度 ÷ 上限步长」更快,这套碰撞就会穿墙——护栏是有参数前提的。

5. 显示层:画一次背景,每帧重画演员

DOMDisplay 把背景画成一张 <table>(行列结构天然对上),格子类型直接当 class 名;scale = 20,即一格 20 像素23。策略分工:背景只画一次(永不改变),演员每帧全部删掉重画——不搞「演员与 DOM 元素一一对应」的复用记账,书里的算账是:场上就那么几个 actor,重画很便宜,记账反而贵24

输赢不用 JS 改样式:把当前 status 拼进容器的 class 名,CSS 写好 .lost .player(焦红)与 .won .player(白色光环)的选择器,状态一换画面自动变——第 12 章「状态当类名」的又一次落地25

镜头(scrollPlayerIntoView)有个手感设计:不是死把玩家钉在屏幕中央(那样一跳画面就跟着颠),而是留出三分之一宽度的中性区,玩家在中性区里怎么动镜头都不滚26。滚出界的坐标也不用管:DOM 会自己钳制,scrollLeft 设成 -10 会归 027

6. 计时与驱动:键盘、帧循环、关卡串

  • 按键:trackKeys 给 keydown/keyup 挂同一个 handler,按事件类型把「这个键按着吗」写成 true/false——游戏要的不是「按了一下」,是按住期间一直为真;顺带 preventDefault,免得方向键滚动页面28;
  • 帧循环:runAnimation 包掉 rAF 的样板代码,回调拿到的时间步换算成秒;关键一行是 Math.min(time − lastTime, 100)——单帧步长上限 100 毫秒:标签页隐藏时 rAF 会暂停,回来若把整段隐藏时间当一步推进,玩家会直接穿地29;
  • 关卡串联:runLevel 返回 promise(输了/赢了先等一秒让玩家看清结局);runGame 用 async 循环串起全部关卡——赢了才 level++,输了原地重来,通关打印 You've won!30

7. 作者的判断与证据

  • 「手感常数是试出来的」:重力 30、跳跃 17、横速 7——原话:拿一些数字试一试,哪个手感对就用哪个31。——交互参数靠调、不靠推导,书里这类判断这是第二次出现;
  • 「DOM 比你想的能扛」:有第 12 章的猫动画与本章实测背书5;
  • 「两个项目章一以贯之」:State 的持久更新与机器人 VillageState.move 是同一纪律的两次落地,本章原文甚至用了同一措辞(persistent data structure)12

判断(我们的,不是书里的): 这一章真正的教学价值在「分层的代价表」: 逻辑层(纯计算,可持久、可测试)、显示层(DOM,每帧扔掉重画)、输入层(键位表)三者只通过 State 对象见面。后来第 13 章的 CanvasDisplay 能无缝换上来,靠的正是 12 章「接口化的显示层」。 如果错,会错在: 帧率敏感的游戏里「每帧全量重画 + 全量重建 State 对象」的垃圾回收压力会真实存在——这个架构对小项目是清晰的胜利,对大项目要换账本。

8. 边界与局限

  • 碰撞只有「矩形 vs 网格」与「矩形 vs 矩形」,斜坡、单向平台、旋转都没有(书里明说是简化做法)22;
  • 显示层只有键盘输入,触屏/手柄不在书内;
  • 关卡数据内嵌在代码字符串里,没有关卡编辑器(第 13 章像素编辑器算半个);
  • 音效、粒子、状态机式的敌人 AI 全无——monster(踩头怪)被留成练习32

9. 可带走的

  1. 关卡=字符画:给人改的格式,解析一次,运行期只跟数据打交道;
  2. levelChars 一张表映射出「类型字符串或 actor 类」——数据驱动的开关;
  3. 状态三件套:level(不变)+ actors + status;每步造新 State;
  4. actor 接口:size/pos/update/type;type 决定画法,create 负责出生;
  5. 两轴分开解碰撞:撞地不挡横移,撞墙不停坠落;
  6. touches:floor/ceil 求覆盖格;界外一律算墙(防出走+防越界);
  7. 起跳的条件写法:「按着上键 且 正在下坠」——一个 ySpeed > 0 挡掉所有二段跳 Bug;
  8. 常数靠手感(7/30/17),镜头留 1/3 中性区,越界坐标交给 DOM 钳制;
  9. 帧步长钳 100 毫秒:隐藏页签回来不穿地;
  10. 输赢用 CSS 类表达,不用 JS 改样式。

10. 原文地图

主题原书章原文位置
游戏生涯忠告与 Dark Blue 原型Project: A Platform Gametext/19-fm-project-a-platform-game.txt:11(搜「game programming」) · :17(搜「Dark Blue」)
空中变向的手感同上text/19-fm-project-a-platform-game.txt:21(搜「midair」)
背景网格 + 小数坐标同上text/19-fm-project-a-platform-game.txt:23(搜「fractional」)
DOM 扛得住同上text/19-fm-project-a-platform-game.txt:33(搜「better at this」)
字符画格式同上text/19-fm-project-a-platform-game.txt:54(搜「Periods are empty space」)
过关与岩浆重来同上text/19-fm-project-a-platform-game.txt:58(搜「restored」)
actors 与背景分离同上text/19-fm-project-a-platform-game.txt:87(搜「actors」)
levelChars 一表两用同上text/19-fm-project-a-platform-game.txt:91(搜「levelChars」)
Vec 与 times同上text/19-fm-project-a-platform-game.txt:93(搜「Vec」) · :137(搜「times」)
State 与持久更新同上text/19-fm-project-a-platform-game.txt:105(搜「playing」) · :115(搜「persistent data structure」)
actor 接口同上text/19-fm-project-a-platform-game.txt:119(搜「same interface」)
出生点上移半格同上text/19-fm-project-a-platform-game.txt:163(搜「half a square」)
size 放原型不放 getter同上text/19-fm-project-a-platform-game.txt:165(搜「wasteful」)
岩浆三形态同上text/19-fm-project-a-platform-game.txt:182(搜「Vec(2, 0)」)
金币随机初相位同上text/19-fm-project-a-platform-game.txt:215(搜「synchronously」)
scale = 20 与表格背景同上text/19-fm-project-a-platform-game.txt:270(搜「scale」) · :282(搜「nicely corresponds」)
演员全量重画同上text/19-fm-project-a-platform-game.txt:316(搜「redrawing all of them」)
status 当类名同上text/19-fm-project-a-platform-game.txt:326(搜「class name」)
三分之一中性区同上text/19-fm-project-a-platform-game.txt:377(搜「neutral」)
scrollLeft 自动钳制同上text/19-fm-project-a-platform-game.txt:375(搜「-10」)
时间步与进墙取消同上text/19-fm-project-a-platform-game.txt:393(搜「units per second」) · :399(搜「cancel the motion」)
小步长藏问题同上text/19-fm-project-a-platform-game.txt:401(搜「hovering」)
touches 与界外算墙同上text/19-fm-project-a-platform-game.txt:422(搜「rounding」) · :424(搜「treated as wall」)
State.update 流程同上text/19-fm-project-a-platform-game.txt:428(搜「State.prototype.update」)
overlap 判定同上text/19-fm-project-a-platform-game.txt:454(搜「function overlap」)
金币收完即胜同上text/19-fm-project-a-platform-game.txt:470(搜「won」)
岩浆速度取反同上text/19-fm-project-a-platform-game.txt:485(搜「-1」)
wobble 常数同上text/19-fm-project-a-platform-game.txt:493(搜「wobbleSpeed」)
三个手感常数同上text/19-fm-project-a-platform-game.txt:506(搜「playerXSpeed」)
起跳条件同上text/19-fm-project-a-platform-game.txt:524(搜「ArrowUp」)
常数靠试同上text/19-fm-project-a-platform-game.txt:538(搜「felt right」)
trackKeys同上text/19-fm-project-a-platform-game.txt:542(搜「once per keypress」) · :564(搜「type property」)
100 毫秒上限同上text/19-fm-project-a-platform-game.txt:576(搜「Math.min」) · :585(搜「falling through the floor」)
runLevel/runGame同上text/19-fm-project-a-platform-game.txt:589(搜「returns a promise」) · :624(搜「async function」)
练习:怪物同上text/19-fm-project-a-platform-game.txt:656(搜「call this actor a monster」)

Footnotes

  1. 出处:「Project: A Platform Game」第 11 段(text/19-fm-project-a-platform-game.txt:11,搜「game programming」)。

  2. 出处:「Project: A Platform Game」第 17 段(text/19-fm-project-a-platform-game.txt:17,搜「Dark Blue」)与第 19 段(text/19-fm-project-a-platform-game.txt:19,搜「dark box」)。

  3. 出处:「Project: A Platform Game」第 21 段(text/19-fm-project-a-platform-game.txt:21,搜「midair」)。

  4. 出处:「Project: A Platform Game」第 23 段(text/19-fm-project-a-platform-game.txt:23,搜「fractional」)。

  5. 出处:「Project: A Platform Game」第 33 段(text/19-fm-project-a-platform-game.txt:33,搜「better at this」)。 2

  6. 出处:「Project: A Platform Game」第 54 段(text/19-fm-project-a-platform-game.txt:54,搜「Periods are empty space」)与第 56 段(text/19-fm-project-a-platform-game.txt:56,搜「dripping」)。

  7. 出处:「Project: A Platform Game」第 58 段(text/19-fm-project-a-platform-game.txt:58,搜「restored」)。

  8. 出处:「Project: A Platform Game」第 91 段(text/19-fm-project-a-platform-game.txt:91,搜「levelChars」)。

  9. 出处:「Project: A Platform Game」第 93 段(text/19-fm-project-a-platform-game.txt:93,搜「Vec」)与第 141 段(text/19-fm-project-a-platform-game.txt:141,搜「times method scales」)。

  10. 出处:「Project: A Platform Game」第 229 段(text/19-fm-project-a-platform-game.txt:229,搜「22 by 9」)。

  11. 出处:「Project: A Platform Game」第 105 段(text/19-fm-project-a-platform-game.txt:105,搜「playing」)与第 113 段(text/19-fm-project-a-platform-game.txt:113,搜「lost or won」)。

  12. 出处:「Project: A Platform Game」第 115 段(text/19-fm-project-a-platform-game.txt:115,搜「persistent data structure」)。 2

  13. 出处:「Project: A Platform Game」第 119 段(text/19-fm-project-a-platform-game.txt:119,搜「same interface」)与第 123 段(text/19-fm-project-a-platform-game.txt:123,搜「type」)。

  14. 出处:「Project: A Platform Game」第 163 段(text/19-fm-project-a-platform-game.txt:163,搜「half a square」)。

  15. 出处:「Project: A Platform Game」第 165 段(text/19-fm-project-a-platform-game.txt:165,搜「wasteful」)。

  16. 出处:「Project: A Platform Game」第 393 段(text/19-fm-project-a-platform-game.txt:393,搜「units per second」)。

  17. 出处:「Project: A Platform Game」第 506 段(text/19-fm-project-a-platform-game.txt:506,搜「playerXSpeed」)。

  18. 出处:「Project: A Platform Game」第 422 段(text/19-fm-project-a-platform-game.txt:422,搜「rounding」)与第 424 段(text/19-fm-project-a-platform-game.txt:424,搜「treated as wall」)。

  19. 出处:「Project: A Platform Game」第 441 段(text/19-fm-project-a-platform-game.txt:441,搜「overlap」)与第 470 段(text/19-fm-project-a-platform-game.txt:470,搜「won」)。

  20. 出处:「Project: A Platform Game」第 485 段(text/19-fm-project-a-platform-game.txt:485,搜「-1」)与第 483 段(text/19-fm-project-a-platform-game.txt:483,搜「reset」)。

  21. 出处:「Project: A Platform Game」第 215 段(text/19-fm-project-a-platform-game.txt:215,搜「synchronously」)。

  22. 出处:「Project: A Platform Game」第 401 段(text/19-fm-project-a-platform-game.txt:401,搜「hovering」)。 2

  23. 出处:「Project: A Platform Game」第 270 段(text/19-fm-project-a-platform-game.txt:270,搜「scale」)与第 282 段(text/19-fm-project-a-platform-game.txt:282,搜「nicely corresponds」)。

  24. 出处:「Project: A Platform Game」第 316 段(text/19-fm-project-a-platform-game.txt:316,搜「redrawing all of them」)。

  25. 出处:「Project: A Platform Game」第 326 段(text/19-fm-project-a-platform-game.txt:326,搜「class name」)与第 335 段(text/19-fm-project-a-platform-game.txt:335,搜「halo」)。

  26. 出处:「Project: A Platform Game」第 351 段(text/19-fm-project-a-platform-game.txt:351,搜「margin」)与第 377 段(text/19-fm-project-a-platform-game.txt:377,搜「neutral」)。

  27. 出处:「Project: A Platform Game」第 375 段(text/19-fm-project-a-platform-game.txt:375,搜「-10」)。

  28. 出处:「Project: A Platform Game」第 542 段(text/19-fm-project-a-platform-game.txt:542,搜「once per keypress」)与第 564 段(text/19-fm-project-a-platform-game.txt:564,搜「type property」)。

  29. 出处:「Project: A Platform Game」第 576 段(text/19-fm-project-a-platform-game.txt:576,搜「Math.min」)与第 585 段(text/19-fm-project-a-platform-game.txt:585,搜「falling through the floor」)。

  30. 出处:「Project: A Platform Game」第 589 段(text/19-fm-project-a-platform-game.txt:589,搜「returns a promise」)与第 624 段(text/19-fm-project-a-platform-game.txt:624,搜「async function」)。

  31. 出处:「Project: A Platform Game」第 538 段(text/19-fm-project-a-platform-game.txt:538,搜「felt right」)。

  32. 出处:「Project: A Platform Game」第 656 段(text/19-fm-project-a-platform-game.txt:656,搜「call this actor a monster」)。