像素艺术的运动:iPhone 上的行走、相机与门
《宝可梦 红》《宝可梦 水晶》《宝可梦 绿宝石》是从 Game Boy 和 Game Boy Advance 三个世代中各挑一款的作品。三者都以约 59.73 赫兹、16 帧走完一个 16 像素的格子,每秒 3.73 格,而且一步之中没有任何部分交给时钟决定:《绿宝石》每帧让玩家恰好移动 1 像素,每一步显示一张迈步画面和一张站立画面,并在同一帧把相机滚动同样的像素数。123 《红》和《水晶》则靠每隔一帧移动 2 像素,凑出同样的 16 帧。4 《绿宝石》的门以四张画面、每张保持 5 帧的方式打开,玩家被强制向里走一步,门关上,屏幕再分九个台阶式的档位淡出:在下一张地图能开始载入之前,至少 79 帧,1.32 秒。15 Kiradex 的世界按经过的时间行走,相机带缓动。按这段代码的模型(是模型,不是手机上的录屏)推算:60 赫兹下,sprite 每走一格会跳一次 2 像素;60 赫兹行走时,相机每走一格就多落后约 1 像素(第五格时落后 9 像素,跑步时 13 像素),停下后停在离玩家还差 4 到 9 像素的地方;行走画面另有自己的时钟,一个循环走 3.00 格,而《绿宝石》的一个循环只覆盖两格。6 Apple 给游戏的 30 和 60 赫兹以特别优先级,RealityKit 通常以 60 渲染,所以这份简报是:固定 60 赫兹的拍(tick)配合整像素步进;按行走距离选择行走画面(这是我的提议,不是掌机的做法);锁定的相机;用我们自己的美术重现《绿宝石》的进门仪式;三个可选的触感事件;以及 60 赫兹而不是 120。78 本文把从反编译里实测的经典、从 Apple 文档读出的 iPhone 一侧,以及这份简报和它必须通过的检查,一并呈上。
TL;DR
- 一步是一份契约,不是一个速度。 《绿宝石》的每张步进表加起来都是 16 像素:行走是每帧 1 像素、共 16 帧(每格 268 毫秒),跑步和冲浪是 2 像素、8 帧,马赫自行车的最高速是 4 像素、4 帧,唯一不均匀的是杂技自行车的 2-3-3-2-3-3。《红》和《水晶》以每秒 30 次更新、每次跳 2 像素,走完同样的 16 帧。124
- 双腿和步子的时序是配好的。 《绿宝石》分别用帧来计数画面和步子,并让两边的数字对上:行走是迈步、站立、迈步、站立,每张画面 8 帧,铺满两个 16 帧的格子;更快的步态不增加画面,而是把保持时间减半,所以在任何速度下,每 16 像素都只落脚一次。站着转身需要 8 帧(134 毫秒),边走边转身不花时间,走向墙壁会播放一段 32 帧的碰撞。19102
- 相机就是玩家。 《绿宝石》的相机复制玩家的位置,在同一帧滚动同样的像素:没有滞后,也没有前瞻;在地图边缘也从不停下,因为外面是用边界图块画出来的。Game Freak 写过一个为自行车前瞻的相机,却在出货时把它关掉了。231112
- 门是一场仪式。 四张各 5 帧的画面(335 毫秒)、一步 16 帧的强制前进、20 帧的关门,再加一段最后一次混合落在第 17 帧、第 22 帧结束的淡出:在地图能载入之前,至少 79 帧,1.32 秒,期间不接受输入。这印证了本系列建筑篇里每张画面约 84 毫秒的数字,也纠正了那篇文章背后的研究笔记,笔记里写的是 “4 ticks each (16 frames, about 0.27 s at 59.7 Hz)”(每张 4 拍,16 帧,59.7Hz 下约 0.27 秒)。《水晶》用 8 帧淡成白色;《红》用 32 帧淡成黑色。1513144
- 在 iPhone 上,以 60 均匀地走。
preferredFrameRateRange(iOS 15.0)只是一个提示;ProMotion iPhone 在 10 到 120 赫兹之间分十二档运行;要超过 60 需要CADisableMinimumFrameDurationOnPhone;游戏享有 “special priority to 30Hz and 60Hz”(对 30Hz 和 60Hz 的特别优先级);RealityKit 则 “typically limits the refresh rate”(通常会限制刷新率)在 60。整像素的行走在 120 下毫无收益:每个像素只是被保持两次刷新而已。1571686 - 今天的 Kiradex,是建模而非实测。 代码按经过的时间以每秒 4 格行走,所以在稳定 60 赫兹的模型里,每格要 15 帧,其中一帧移动 2 像素;相机先缓动再取整,走得越久落后越多(第一格后 5 像素,第五格后 9 像素,跑步时 13 像素),停下后在 60 赫兹下停在离玩家 4 像素处,120 赫兹下 9 像素;六张画面的行走以每秒 8 张播放,一个循环行走覆盖 3.00 格,跑步覆盖 4.50 格。还没有在任何一台手机上实测过。176
- Kiradex 得到的是一份简报,而不是已出货的构建。 固定 60 赫兹的拍、每拍 1 像素并写明积压规则;按距离选择行走画面;锁定在整像素上的相机;带台阶式淡出的完整进门流程;把被拒绝的一步展示出来;三个可选的触感事件;不请求 120 赫兹;每一项都附带一个可以用运动日志、录屏或脚本验证的检查。
1. 《绿宝石》的行走:16 帧走 16 像素
本系列前两篇测量了一个像素世界和其中的人长什么样,第三篇测量了它的建筑。这一篇测量的是时间:每帧走几像素,每步要几帧,哪一帧显示哪张画面,转身、开门和淡出各花多久,以及在这一切发生时相机在做什么。读游戏的方式与前几篇相同:读 pret 对已发售的 Game Boy 和 Game Boy Advance 游戏所做的反编译,用的也是前几篇所用的提交:pokered d2704a6、pokecrystal 5beda23、pokeemerald 731ad5b。18 计数由五个小脚本完成,每个脚本都在注释里写明了名字和保存下来的输出。
先说一个数字,因为其他一切都以帧为单位。Game Boy Advance 用 2^24 赫兹时钟的 280,896 个周期画完一帧,也就是每秒 59.7275 帧,每帧 16.743 毫秒;GBATEK 把它取整为 “ca. 59.737 Hz”(约 59.737Hz)。19 初代 Game Boy 的 4,194,304 赫兹时钟除以每帧 70,224 个点,得到同样的 59.7275;Pan Docs 写作 “@ 59.7 fps”。19 本文中的毫秒数一律按 59.7275 换算。19
每种速度都是一张加起来等于十六的表
《绿宝石》移动行走者时不用速度乘以时间。每种野外速度都是一张逐帧像素位移的表,而源码写明了所有表的共同点:“Over the course of the step animation, these sum to 16 pixels (one full metatile).”(在一步的动画过程中,这些值加起来是 16 像素,即一整块元图块。)2 sStep1Funcs 是十六个 1 像素位移,sStep2Funcs 是八个 2 像素位移,sStep4Funcs 是四个 4 像素,sStep8Funcs 是两个 8 像素,而 sStep3Funcs 是那个例外:Step2, Step3, Step3, Step2, Step3, Step3。2 用脚本对照移动源码计数,结果如下:
| 速度常量 | 每帧像素 | 每格帧数 | 每秒格数 | 每格毫秒 | 用途 |
|---|---|---|---|---|---|
MOVE_SPEED_NORMAL |
每帧 1 | 16 | 3.73 | 267.9 | 行走;NPC 行走 |
MOVE_SPEED_FAST_1 |
每帧 2 | 8 | 7.47 | 133.9 | 跑步、冲浪、冰面滑行 |
MOVE_SPEED_FAST_2 |
2, 3, 3, 2, 3, 3 | 6 | 9.95 | 100.5 | 杂技自行车、水流 |
MOVE_SPEED_FASTER |
每帧 4 | 4 | 14.93 | 67.0 | 最高速的马赫自行车 |
MOVE_SPEED_FASTEST |
8, 8 | 2 | 29.86 | 33.5 | 滑动类移动动作 |
每一行的出处:对 src/event_object_movement.c 运行的 measure_gen3_motion.py。1
要记住的数字是行走:每帧 1 像素,每格 16 帧,每秒 3.73 格,每格 268 毫秒,PlayerWalkNormal 和所有行走的 NPC 都用它。1 按住 B 键跑步是 PlayerRun,冲浪是 PlayerWalkFast,源码给后者注了一句 “same speed as running”(与跑步速度相同);冰面滑行调用的也是同一个函数。三者都是每帧 2 像素、每格 8 帧、每秒 7.47 格。110 另有一种慢走 UpdateWalkSlowAnim,在计时器为偶数时走 1 像素,每格 31 到 32 帧,用于脚本演出。1
像素不均匀的速度只有杂技自行车:六帧走 2, 3, 3, 2, 3, 3,平均每帧 2.67 像素,每秒 9.95 格。1 它经由 AcroBikeTransition_Moving 调用 PlayerRideWaterCurrent 达到,水流用的也是这个函数。120 依我的解读,这种不均匀是选了一个不能整除十六的速度所付出的代价:每帧 3 像素会冲过格子,所以表里让 2 和 3 交替出现,好恰好落在 16 上。其余所有速度都是格子的整除数。
马赫自行车按步而不是按帧加速。sMachBikeSpeedCallbacks 是 PlayerWalkNormal, PlayerWalkFast, PlayerWalkFaster,bikeFrameCounter 每走一步加一,上限为 2,所以从静止起步的第一步要 16 帧,第二步 8 帧,之后每步 4 帧:每帧 4 像素,每秒 14.93 格。120 自行车从不处于两种速度之间。每一步都是把某一张表完整跑完,速度只在格子边界上改变。
在《红》《水晶》《绿宝石》中测得的每种步态,每个 16 像素格都是整数帧;Kiradex 的数字是代码的模型,不是录屏。14621
每 16 像素落脚一次
行走动画是迈步、站立、迈步、站立。sAnim_GoSouth 是 ANIMCMD_FRAME(3, 8), (0, 8), (4, 8), (0, 8):画面 3 保持八帧,站立画面 0 保持八帧,画面 4 保持八帧,再回到站立画面八帧,共 32 帧,相当于走两格。91 保持时间是精确的:sprite.c 把帧时长减一载入延时计数器并倒数到零,所以时长为 8 的帧会在屏幕上停留八帧。91 因此每一步都显示一张迈步画面和一张站立画面;而 SetStepAnimHandleAlternation 让每一个新步从循环的另一半开始(animPos = {1, 3, 0, 2}),所以即使玩家走走停停,左右腿也会逐步交替。2 本系列第一篇从美术一侧描述过同一个循环:“step, stand, step, stand at eight ticks each, which is the bob everyone remembers.”(迈、站、迈、站,每张八拍,就是人人都记得的那种一颠一颠。)22
更快的步态保留画面、缩短保持时间。GoFast 每张保持四帧(16 帧一个循环),GoFaster 两帧(8 帧),GoFastest 一帧(4 帧)。1 跑步有自己的画面,保持时间不均匀:sAnim_RunSouth 是 (12, 5), (9, 3), (13, 5), (9, 3),16 帧一个循环。19
把两张表并排放,设计就显现出来了。行走的 32 帧循环覆盖两个 16 帧的格子;跑步的 16 帧循环覆盖两个 8 帧的格子;马赫自行车 8 帧的 GoFaster 循环覆盖两个 4 帧的格子。19 在任何速度下,每 16 像素都落脚一次。1 这是两只时钟,而不是一个计数器。画面在 animDelayCounter 走完时前进,它在 sprite.c 里由每帧的时长载入;步子在 NpcTakeStep 用 sprite 的 sTimer 逐帧索引速度表时前进;而 SetStepAnimHandleAlternation 在每一步开始时挑选该步态的动画。92 让二者保持一致的,是它们都以同样的帧计数,而且时长是逐个步态挑好、彼此对上的。依我的解读,这正是值得照搬的地方:每一步的画面恰好持续这一步那么多帧,所以画面的节奏不可能偏离运动。这些表和我的模型都没有测量踩下的脚落在地面何处,所以我不作更多的断言。
转身、碰撞与读取输入的时机
一步一旦开始就会走完;手柄在一步完成时才被读取,所以按住方向键时步与步之间没有空隙,走动中改变方向也没有代价。10 从站立状态出发就不一样了。CheckMovementInputNotOnBike 只有在新方向与当前朝向不同、且玩家尚未移动时才返回 TURN_DIRECTION,此时 PlayerTurnInPlace 播放快速原地踏步,而 InitMoveInPlace 给它的时长是 8 帧:原地转身 134 毫秒。101 正是这个机制,让玩家不必朝告示牌或人物迈出一步就能面向它。走向墙壁则改为播放慢速原地踏步,32 帧,536 毫秒,并伴随碰撞音效:被拒绝的一步会被展示出来,而不是被悄悄吞掉。110 普通和更快(faster)的原地踏步分别是 16 帧(268 毫秒)和 4 帧(67 毫秒)。1
依我的解读,这四个数字就是网格游戏里所谓 “跟手” 的大部分内容。世界从不移动零点几格,所以玩家的意图以整步来表达,唯一的延迟就是进行中这一步剩下的部分:行走时最多 268 毫秒,跑步时 134 毫秒。1 站立时的转身不是延迟,而是一个单独的动作,有它自己可见的结果。
2. 《绿宝石》的相机、门、淡入淡出与震动
相机没有滞后,也没有前瞻
《绿宝石》的相机是一个跟随玩家的隐形 sprite。CameraObject_UpdateMove 复制被跟随 sprite 的 x 和 y,并把与上一帧的差值存进 sCamera_MoveX 和 sCamera_MoveY;CameraUpdateCallback 把这个差值交给 CameraUpdate,后者把地图恰好滚动这么多像素。212 野外循环每帧按这个顺序运行 RunTasks(); AnimateSprites(); CameraUpdate(); UpdateCameraPanning();;又因为 AnimateSprites 按槽位顺序运行 sprite 回调,而相机对象在玩家之后创建(先 InitPlayerAvatar,再 InitCameraUpdateCallback(gPlayerAvatar.spriteId)),所以相机读到的是玩家在同一帧到达的位置。3 这个顺序是我沿着代码读出来的,没有实际运行,所以它是解读而不是测量;但它所蕴含的结果很简单:玩家的移动和画面的滚动发生在同一帧,移动同样的像素。在屏幕上,玩家根本不动,世界在他脚下滑过。
Itay Keren 在 GDC 2015 关于横版卷轴相机的演讲里,把这种做法称为 position-locking(位置锁定):相机始终跟着玩家,“keeping the car in focus at all times and the camera motion completely predictable.”(让车始终处于焦点之中,相机的运动完全可预测。)23 他的定义留了一个问题没有回答,即到了地图边缘会怎样,《绿宝石》补上了答案:它从不停下。地图布局之外的格子通过 GetBorderBlockAt 读取,它返回布局里 2×2 的边界元图块的重复,并标记为 MAPGRID_IMPASSABLE,于是视野让玩家保持在屏幕的同一位置,用边界上的树或水填满外面。11 作为替代的 Keren 的 edge-snapping(边缘吸附),则是 “simply snaps the camera to the edge of the level, allowing the character to move away from its anchor point.”(把相机直接吸附在关卡边缘,让角色离开它的锚点移动。)23 《绿宝石》用画出来的方式,让自己用不着它。
Game Freak 写好又关掉的相机
field_camera.c 里有一个为自行车完成的前瞻相机。CameraPanningCB_PanAhead 把纵向平移每次更新挪动 2 像素,从静止值 32 朝 72 或负 8 移动,视行进方向而定,两个方向都是 40 像素:一个领着玩家看向前方的相机。12 它只在 gUnusedBikeCameraAheadPanback 为真时运行,而这个变量只被赋过 FALSE(在 bike.c 里),那个分支还带着注释 “this code is never reached.”(这段代码永远不会被执行到。)1220 用 Keren 的词汇来说,它是一种 dual-forward-focus(双向前瞻)或 target-focus(目标聚焦),即遵循他那条规则的相机:“When you walk left, you want to see more to the left.”(往左走时,你想看到更多左边的东西。)23 不管关掉它的原因是什么,出货的游戏即使在马赫自行车每帧 4 像素时,也让相机保持锁定。1
门要 20 帧才开,不是 16 帧
field_door.c 里的门表读起来像是每张画面 4 帧:sDoorOpenAnimFrames 是 {4, -1}, {4, 0}, {4, 0x100}, {4, 0x200},一张关着的加三张开着的。24 但 AnimateDoorFrame 在计数器为 0 时绘制,在计数器等于该条目的时间时前进,所以每张画面保持五次更新:每张 84 毫秒,四张共 335 毫秒。241 本系列建筑篇给出的是同一个数字,五次更新,约 84 毫秒。13 那篇文章所依据的研究笔记写错了,写成 “4 ticks each (16 frames, about 0.27 s at 59.7 Hz)”(每张 4 拍,16 帧,59.7Hz 下约 0.27 秒);应用自己的门代码里的一条注释也是如此,说门 “at about Emerald’s four ticks a frame”(大约按《绿宝石》每张 4 拍)打开,然后让第一张画面保持 70 毫秒。两者都在本文和简报中更正。1417
进门这件事本身由 Task_DoDoorWarp 负责,它有五个状态,没有一个可以跳过:冻结其他对象,播放门的音效并打开玩家上方的门;强制走一步 MOVEMENT_ACTION_WALK_NORMAL_UP 进入门口;玩家站定后关门并隐藏玩家;门的任务结束后,淡出音乐和画面;载入地图。25 出门则把仪式倒过来:Task_ExitDoor 先显示已经打开的门,等待淡入,强制走一步 WALK_NORMAL_DOWN,关门,然后才把操作权交还。25
把上面的计数和下文淡出的追踪加起来:门打开 20 帧,一步 16 帧,门关上 20 帧;随后的淡出在它的第 17 帧做最后一次混合,所以屏幕至少在 73 帧、1.22 秒之后才完全变暗。地图要等到淡出在第 22 帧变为非活动、且 Task_WarpAndLoadMap 看到这一点并继续之后,才能开始载入:至少 79 帧,1.32 秒。525 门的各阶段之间的状态切换,以及等待音乐停止的时间,只会增加帧数,所以两个数字都是下限。5
《绿宝石》的进门是由帧数推出的下限;Kiradex 一行读自应用的门代码,没有在手机上计时。151721
一次淡出是九个档位
《绿宝石》的传送淡出不是一道平滑的斜坡。BeginNormalPaletteFade 给一个从 0 到 16 的混合系数设定步长 2,UpdateNormalPaletteFade 在一次调用里混合背景调色板,下一次调用里混合 sprite 调色板,然后把系数推进一步,而 IsSoftwarePaletteFadeFinishing 在末尾再加五次调用。26 每次调用落在哪一帧,取决于由谁来调用。门的任务在 RunTasks 内部启动淡出,BeginNormalPaletteFade 自己先执行一次更新,立即把结果复制到调色板内存,并清除那个原本会让下一次更新等待垂直消隐的标志;在同一帧稍后,OverworldBasic 再调用一次 UpdatePaletteFade。25263 所以第一帧有两次更新,都在档位 0,之后每帧一次。按这个时间表把 palette.c 移植到 Python,并把任务所在的帧记为 0,得到九个档位,0、2、4,依此类推直到 16:背景调色板在第 1 帧到达档位 2,在第 15 帧到达档位 16,sprite 调色板每次都晚一帧;最后一次混合在第 16 帧(从第 0 帧算起 285 毫秒),淡出在第 21 帧变为非活动(368 毫秒)。5 某一帧算出的混合,会在结束这一帧的垂直消隐时上屏。这个移植假设之前没有正在进行的淡出,且是普通的野外更新;如果下雨、下雪、起雾、阴影或干旱处于活动状态,淡出会以同样的方式从被天气染色的颜色开始(FadeScreen 先复制染色后的缓冲区,再调用同一个 BeginNormalPaletteFade),但淡入由天气代码执行,那条路径没有模拟。5
九个档位,相隔十六分之二,背景和 sprite 相差一帧:这就是简报改编成一层蒙版的那道斜坡。521
传送会淡出到黑色,只有一类例外,而且这个例外是有方向的。WarpFadeOutScreen 拿两个地图类型的组合去问 GetMapPairFadeToType,WarpFadeInScreen 去问 GetMapPairFadeFromType,答案为真就以白色调用 FadeScreen,否则以黑色调用。25 两者都在 sTransitionTypes 里查这一组合,这张表的 16 行是所有地图类型进出 MAP_TYPE_UNDERGROUND 的组合;前者返回某行的进入标志,后者返回其离开标志,而它们分别只在进入洞窟的行和离开洞窟的行上为真。27 所以进洞时,屏幕淡出到白色、再从黑色淡入;出洞时,淡出到黑色、再从白色淡入。每一行还各自指定了一个洞窟过渡例程,我没有追踪。27 一种更慢的白色淡入,延迟为 8 的 FadeInFromWhite,在 86 或 87 帧、1.44 到 1.46 秒后变为非活动;它从地图载入回调中启动,那个回调的第一帧我没有追踪,所以移植给出了两种情况。525
震动是一段写进脚本的平移
《绿宝石》的屏幕震动是一条脚本命令,而不是物理效果。ShakeCamera 从四个脚本变量读取纵向平移、横向平移、震动次数和两次震动之间的帧数,每震一次就把平移取反。28 整个游戏的脚本中,9 个文件里共有 24 次调用,一个脚本把它们全部数了出来:29
| 纵向 px | 横向 px | 震动次数 | 间隔帧数 | 调用次数 | 时长 |
|---|---|---|---|---|---|
| 1 | 1 | 8 | 5 | 10 | 40 帧,670 ms |
| 1 | 1 | 8 | 3 | 4 | 24 帧,402 ms |
| 1 | 2 | 8 | 5 | 3 | 40 帧,670 ms |
| 2 | 2 | 8 | 5 | 2 | 40 帧,670 ms |
| 0 | 3 | 4 | 2 | 2 | 8 帧,134 ms |
| 1 | 3 | 20 | 5 | 1 | 100 帧,1,674 ms |
| 1 | 1 | 16 | 3 | 1 | 48 帧,804 ms |
| 1 | 1 | 32 | 2 | 1 | 64 帧,1,072 ms |
出处:对 data/**/*.inc 全部运行的 measure_gen3_shake.py。29
24 次里有 10 次是同一种震动:每个方向 1 像素,翻转八次,间隔五帧,670 毫秒。29 最大的是横向 3 像素;最长的是 100 帧,1.67 秒。29 建筑篇讲过的电梯震动(每三帧震一次,次数随经过的楼层增加)是另一个例程,不在这次统计之内。13 依我的解读,克制就是这里的教训:《绿宝石》里的震动只有一两个像素,只用于脚本认定重要的事件,从不用于脚步或开门。
3. 《红》与《水晶》:每秒 30 次更新走出同样的一步
《红》每隔一帧移动两像素
《宝可梦 红》没有步进表。它的野外循环 OverworldLoop 调用 DelayFrame,然后落入 OverworldLoopLessDelay,后者再调用一次,所以世界每两帧更新一次。30 一步开始时把 wWalkCounter 设为 8,每轮 AdvancePlayerSprite 把它减一,并把背景寄存器 hSCX 和 hSCY 按左移一位后的步进向量滚动:2 像素。30 8 轮各 2 像素,就是 16 帧走 16 像素,与《绿宝石》同样的 268 毫秒、每秒 3.73 格,只是以每秒 30 次更新、每次跳 2 像素。430 自行车则是每轮再推进一次:DoBikeSpeedup 再调用一次 AdvancePlayerSprite(在自行车道上按住上、左或右时除外),所以一步要 8 帧。430
《红》的行走画面每四轮、即八帧更换一次,在四张图之间循环:站立、迈步、站立、翻转的迈步,所以它同样每步显示一张迈步画面。3122 UpdatePlayerSprite 每轮递增动画内部计数器,计到 4 时推进画面。3122
《红》转身只需一轮循环,两帧:站立时输入新方向,就写入朝向,不迈步直接回到循环。30 它的 180 度转身在代码里有一个中间朝向,但按源码自己的注释,没有人看得到:“It is unlikely for it to ever be visible because DelayFrame is called at the start of OverworldLoop.”(由于 OverworldLoop 一开始就调用 DelayFrame,它几乎不可能被看到。)30 这是我从代码里读出的,没有在模拟器里运行。
传送只有音效和淡出,没有画门。PlayMapChangeSound 在图块是门(图块 $0b)时播放 SFX_GO_INSIDE,否则播放 SFX_GO_OUTSIDE,然后 GBFadeOutToBlack 写入四组调色板,每组保持八帧:32 帧,536 毫秒。432 《红》的淡入我没有追踪。
《水晶》也是每隔一帧更新
《水晶》用不同的机制保留了《红》的节奏。MaxOverworldDelay 是 db 2,VBlank 处理程序倒数 wOverworldDelay,所以地图对象每两帧更新一次。33 StepVectors 给行走八次 2 像素的更新,给自行车四次 4 像素的更新:每格 16 帧和 8 帧,与《红》和《绿宝石》相同。4 慢步是十六次 1 像素的更新,32 帧,每秒 1.87 格。433
《水晶》的门淡出到白色,而且很快。MapSetupScript_Door 以 FadeOutToWhite 开头,MapSetupScript_Warp 以 FadeInFromWhite 结尾;两者都是四个间隔两帧的调色板台阶,单程 8 帧,134 毫秒。434 它的转身是一个分四部分的步进函数 StepFunction_Turn,各部分依次贯穿到下一部分。第一次更新时,.init1 把 OBJECT_STEP_DURATION 设为 2 并落入 .step1,后者把它减到 1;第二次更新时,.step1 把它减到 0,穿过写入新朝向并重新设为 2 的 .init2,落入 .step2,后者把它减到 1;第三次更新时,.step2 减到 0,把对象交还给 STEP_TYPE_FROM_MOVEMENT。33 逐条指令重放,这个例程要三次更新,按每次更新两帧计是六帧,新朝向在第二次写入。这只是例程本身,读自代码;从按下按键到例程开始之间的时间我没有追踪。33
三个世代,三种淡出
| 《红》 | 《水晶》 | 《绿宝石》 | |
|---|---|---|---|
| 门 | 不画;音效按图块选择 | 不画 | 4 张画面各 5 帧(335 ms),配推拉门或开合门的音效 |
| 进门 | 落到门上的那一步 | 落到门上的那一步 | 强制向上走一步(16 帧),随后门关上 |
| 淡出 | 到黑色,4 组调色板各保持 8 帧:32 帧(536 ms) | 到白色,4 个台阶间隔 2 帧:8 帧(134 ms) | 到黑色(进洞时到白色),9 个档位,以门任务的帧为第 0 帧,最后一次混合在第 16 帧(285 ms),第 21 帧变为非活动 |
| 淡入 | 未追踪 | 从白色,8 帧 | 从黑色(出洞时从白色),同样 9 个档位 |
| 出门 | 强制向下走一步 | 强制走一步 | 显示打开的门,强制向下走一步,门关上,然后交还操作 |
出处:第 1 至第 3 节的计数;14525 《红》出门时的强制一步 PlayerStepOutFromDoor 来自建筑篇。13
三款都一致的那几行,才是值得保留的:地图之间的淡出,把玩家带过门槛的强制一步,以及仪式期间不接受输入。淡出时长相差四倍,所以依我的解读,时长是设计选择而不是定规。围绕画出来的门构建的是《绿宝石》的九档淡出,而 Kiradex 也画门,13 所以简报采用它。
《红》《水晶》《绿宝石》来自 Game Boy 和 Game Boy Advance 这两台不同机器上的三个世代,各取一款,却落在了同一份契约上:一步是 16 像素,约 59.73 赫兹下要 16 帧,一旦开始就不能打断。14 《红》因为循环每轮等两帧,用 2 像素的跳跃到达那里;《水晶》靠同样的两帧延迟加 2 像素向量;《绿宝石》则在全帧率下以 1 像素移动。跑步、冲浪和自行车,是同一份契约的 8 帧或 4 帧版本。14 当人们说这些游戏手感像 “在轨道上” 时,我认为这条轨道就是这个:步子、画面和相机都按同样的帧来计数,时长被挑选得彼此对上,所以它们不可能彼此错开。
4. 现代参照:Celeste 的相机、Keren 的词汇,以及手机移植版
相机何时该平滑,何时不该
《绿宝石》的锁定相机,是 Itay Keren 整理的一套词汇里的一个答案。这套词汇出自 “Scroll Back: The Theory and Practice of Cameras in Side-Scrollers”(横版卷轴游戏相机的理论与实践),是他在 GDC 2015 独立游戏峰会(Independent Games Summit)上演讲的修订版,2015 年 5 月 11 日发表于 Game Developer。23 本文用的术语都是他的。position-locking(位置锁定)把相机固定在玩家身上。edge-snapping(边缘吸附)让相机在关卡边缘停下。camera-window(相机窗口)只在玩家推到窗口边缘时才移动相机。lerp-smoothing(lerp 平滑)让相机向目标缓动,他称之为 “a standard tool in reducing jarring camera speeds, particularly jumps.”(减轻突兀的相机速度、尤其是跳跃时的标准工具。)target-focus(目标聚焦)和 dual-forward-focus(双向前瞻)则让相机领着玩家看向行进方向。他还列了 platform-snapping(平台吸附)、region-focus(区域聚焦)和 cue attractors(提示吸引点),这些对网格行走的游戏都没有用处。23
他给出了相机运动之所以要紧的理由:“conflicting sensory signals (Visual vs. Vestibular) may lead to discomfort and nausea, and though it’s worse in 3D (especially VR), it is still very much in effect in 2D games.”(视觉与前庭感觉的信号冲突可能导致不适和恶心,虽然在 3D、尤其是 VR 中更严重,但在 2D 游戏里依然明显存在。)他也给出了最朴素的方案恰恰正确的情形:位置锁定,对于 “a crafting adventure game like Terraria, with a small character relative to the screen with pretty small jumps, it works very well.”(像《泰拉瑞亚》这样的建造冒险游戏,角色相对屏幕很小、跳跃也很小,效果非常好。)23 一座有 30 像素收藏者的俯视小镇(这是本系列角色篇在第 34 版构建时改用的尺寸),正是去掉了跳跃的那种情形。35
另一种选择的现代参照是 Celeste。它的开发者把 Player 类 “as a learning resource and for general interest”(作为学习资料,也出于大众兴趣)公开,MIT 许可证只覆盖这部分代码,而相机就是其中的几行:level.Camera.Position = from + (target - from) * (1f - (float)Math.Pow(0.01f / multiplier, Engine.DeltaTime)),位于注释 “Camera (lerp by distance using delta-time)”(相机:按距离、使用 delta 时间做 lerp)之下。3637 当 multiplier 为 1、目标静止不动时,不论帧率如何,它每秒都会缩小 99% 的差距;但 Celeste 的目标并不静止,它每帧都根据玩家的位置和状态重新计算,所以这个数字描述的是缓动的性质,而不是相机最终停在哪里。36 目标是让玩家位于游戏视野中央的位置(X - Celeste.GameWidth / 2),再按房间边界夹住,少数状态会加上偏移:StRedDash 冲刺时在行进方向前方 48 像素,登顶弹射时向上 64 像素。36 本系列第一篇提到过,Celeste 以 320×180 渲染世界,再放大六倍。22
依我的解读,两者并不矛盾。Celeste 之所以平滑,是因为平台跳跃游戏的每一次跳跃都会把视野上下拖动;Keren 本人对 lerp 平滑的定义,也正是关于跳跃的。网格行走者以恒定速度走直线,所以锁定不会产生需要抹平的颠簸,平滑的相机只会在本已均匀的运动后面拖出一道尾巴。第 7 节会展示这道尾巴在 Kiradex 的模型里量出来有多大。
其他游戏在速度上告诉了我们什么
《星露谷物语》(Stardew Valley)把玩家速度写成一个无单位的属性:“2 when walking”(行走时为 2)、“5 when running”(跑步时为 5)、“6.6 when riding a Horse (7 if the horse was fed a carrot that day)”(骑马时为 6.6,若当天喂过胡萝卜则为 7),且从不低于 1。38 维基没有说明一个单位是每拍多少像素,所以这里不给星露谷的每秒格数。
我也找过《星之海》(Sea of Stars)、《风来之国》(Eastward)和《远星物语》(CrossCode)相机方面的一手资料,没有找到:有关于移动和光照的访谈,却没有谈相机的,而《星之海》的 “Pixel Perfect” 选项只有攻略和论坛在描述。也没有找到 Maddy Thorson 谈相机的演讲或文章,所以 Celeste 是从代码引用的。39
在手机上:点按行走,并留一条退路
《星露谷物语》的移动版提供九种操控方案,默认的是 “Tap-to-move & Auto-Attack”(点按移动与自动攻击):“Tap anywhere on screen and the farmer will walk to where you tapped.”(点按屏幕任意位置,农夫就会走到你点按的地方。)40 手指按住不放 “will cause the character to follow the touch”(会让角色跟随触点),维基提醒说这种跟随模式 “is very literal, moving directly towards the finger without routing around blocking objects.”(非常死板,会径直朝手指移动,不会绕开阻挡物。)隐形摇杆方案占用 “the left half of the screen”(屏幕左半边),中心就在你触碰的位置。维基也坦白地说出了默认方案的局限:有些需要精确定位的任务 “can not be completed using the default controls; temporarily switching to a control style with a movement joystick is necessary in such cases.”(无法用默认操控完成;这种情况下需要临时切换到带移动摇杆的操控方式。)40 这些方案是在 TouchArcade 于 2018 年 11 月 1 日报道的一次更新中加入的,还附带一个开关,可以退回 “the default tap-to-move and auto-attack controls.”(默认的点按移动与自动攻击操控。)41
Square Enix 为初代《最终幻想》推出的 iOS 版像素复刻版,在该应用 App Store 历史中标为 2025 年 3 月 11 日的 1.2.0 版里,加入了行走或跑步的默认设置(该系列各作以独立应用发行,我只查看了这一款):“In tap based movement mode the character controlled will always run as the default speed when moving.”(在点按移动模式下,受控角色移动时总以跑步作为默认速度。)42 移动版的手柄支持更早,来自 TouchArcade 在 2024 年 1 月 30 日报道的一次更新。43 除了这些说明之外,我没能找到任何地方记录它确切的触控移动方案,在我抓取的维基页面上也没有找到对《泰拉瑞亚》移动版移动方式的描述。39
Apple 的 Human Interface Guidelines 从平台一侧讲了同样的事。对触控游戏,它说 “consider letting players tap objects to select them instead of adding a virtual selection button”(考虑让玩家点按对象来选择,而不是添加虚拟选择按钮);“For movement control, opt to show a virtual thumbstick wherever the player lands their thumb instead of a static thumbstick position”(对于移动操控,在玩家拇指落下的位置显示虚拟摇杆,而不是固定位置的摇杆);“Make sure frequently used controls are a minimum size of 44x44 pt”(确保常用控件至少为 44x44 pt);“Always include visible and tactile press states”(始终提供可见且可触知的按下状态);对于行走和冲刺,则 “consider combining the actions into a single control.”(考虑把这两个动作合并为一个控件。)该页的更新记录把这些触控实践标注为 2025 年 6 月 9 日。44 在 WWDC25 关于触控操控的讲座上,Apple 把前提说得很直白:“the vast majority of players won’t have a controller available.”(绝大多数玩家手边不会有手柄。)45
这些资料都没有说点按行走是手机游戏的规则,我也不作此断言。我从中得出的,是我对 Kiradex 的建议,而非发现,也就是 Kiradex 已经做到一半的方案:以点按行走为默认,就像星露谷移动版出货时那样;为精确操作提供一个在拇指落下处出现的摇杆,就像 HIG 所建议的;不设固定在屏幕上的方向键。4044
5. 手艺,写成带数字的规则
第 7 节的简报就是对照这些规则写的,每条规则都可以追溯到上面的某项测量。“拍” 指一次 1/60 秒的模拟步进,这是把 59.73 赫兹的一帧换算成 iPhone 能够守住的单位;按每格 16 拍计,行走是每秒 3.75 格,而不是 3.73 格。119
| 要素 | 规则 | 出处 |
|---|---|---|
| 行走 | 在 16 像素图块上每拍 1 像素:每格 16 拍,每秒 3.75 格 | 《红》《水晶》《绿宝石》都以 16 帧走 16 像素,每秒 3.73 格 |
| 跑步 | 每拍 2 像素,每格 8 拍;不用不能整除 16 的速度 | 《绿宝石》的跑步和冲浪是 2,自行车是 4;唯一不均匀的是杂技自行车的 2-3-3 |
| 行走循环 | 任何速度下每 16 像素落脚一次;我对 Kiradex 的提议是按行走距离选择画面,六张画面覆盖 32 像素时显示画面 floor(distance × 6 / 32) mod 6 |
《绿宝石》用帧分别为画面和步子计时,长度相互匹配:行走是覆盖两个 16 帧格子的 32 帧循环,跑步是覆盖两个 8 帧格子的 16 帧循环 |
| 一步 | 一旦开始就走完;在图块上读取输入 | 《绿宝石》和《红》在一步完成时读取手柄 |
| 转身 | 站立时 8 拍,约 133 ms(《绿宝石》的 8 帧是 134);行走中不需要 | 《绿宝石》WalkInPlaceFast 为 8;《水晶》的转身例程 6 帧;《红》2 帧 |
| 被拒绝的一步 | 展示出来而不是忽略:一段 32 拍、带碰撞的原地踏步 | 《绿宝石》WalkInPlaceSlow 为 32 |
| 相机 | 锁定在玩家身上:没有滞后、没有前瞻,在同一拍移动同样的像素 | 《绿宝石》的相机对象;Game Freak 写过的唯一一个前瞻被关掉了 |
| 地图边缘 | 画出外面并保持锁定,或者夹住(边缘吸附);绝不缓动 | 《绿宝石》的边界图块;Keren 的边缘吸附;Celeste 的房间边界 |
| 门 | 4 张画面各保持 5 拍:每张 83 ms,打开共 333 ms(《绿宝石》为 84 和 335) | 《绿宝石》field_door.c |
| 淡出 | 9 个台阶式档位,每 2 拍一档,最后一档在第 16 拍,共 18 拍(0.30 秒),出入皆然;默认黑色。是改编,不是照搬 | 《绿宝石》的普通淡出在 sprite 调色板上于同样的偶数帧到达各档位,比背景调色板晚一帧,最后一次混合在第 16 帧,第 21 帧变为非活动;进洞时淡出到白色,出洞时从白色淡入;《水晶》的 8 帧是快的一端,《红》的 32 帧是慢的一端 |
| 进门仪式 | 74 拍,约 1.23 秒,不接受输入:开门 20,进门一步 16,关门 20,淡出 18 | 《绿宝石》的进门:至少 73 帧后变暗,地图载入最早在第 79 帧(1.32 秒) |
| 震动 | 1 像素,翻转 8 次,间隔 5 拍(40 拍,0.67 秒),只用于脚本事件 | 《绿宝石》24 次震动中最常见的一种 |
| 帧率 | 不管显示屏怎样,都以 60 模拟;请求 60,而不是 120 | Apple 对游戏的 30 和 60 优先级;RealityKit 通常以 60 渲染 |
| 触感反馈 | 确认事件而非脚步;设为可选 | Apple 关于播放触感反馈的 HIG |
| 输入 | 我的建议:默认点按行走;以浮动摇杆作为精确操作的选项;不设固定方向键 | 星露谷移动版的默认方案,HIG 的浮动摇杆;两者都没有把它当作规则来陈述 |
表格出处:《绿宝石》的行走、动画和门的计数;1 它分开的画面与步子计时器;92 它的相机;123 它的淡出和震动;529 《红》和《水晶》;433 Keren 和 Celeste;2336 Apple 的帧节奏、RealityKit 和触感反馈指南;7846 输入方面的参照。404244
其中两条规则需要各补一句。行走循环这条,是现代引擎里最容易做错的,因为动画系统数的是它自己的秒,而行走数的是世界里的距离。掌机把两者都按同样的帧来计数,并把长度挑得彼此对上,从而让它们保持一致;在帧时长会变化的手机上,我的提议是从行走的距离读出画面,这样不需要第二只要去对齐的时钟,也能得到同样的结果。帧率这条也不是缺乏雄心。一个每拍恰好移动一个像素的世界,在两拍之间没有任何东西可显示,所以更快的显示屏只能重复同一幅画面,第 6 节会表明它确实正是如此。
6. Apple 的做法:帧节奏、RealityKit 的时钟与触感反馈
本系列第一篇介绍了 Kiradex 世界所运行的引擎:一个当作 2D 渲染器用的 RealityKit 场景、一台正交相机、由带纹理的四边形合成一张网格的地面、用四边形表示的行走者,以及每帧都取整到整数世界单位的每个 sprite 位置。22 本节是 Apple 文档中决定这个世界如何随时间运动的部分。下面每一页都按 Apple 在 2026 年 10 月 4 日发布的样子阅读,所列的系统版本以各页自己的标注为准。
ProMotion 上的帧节奏
CADisplayLink.preferredFrameRateRange(iOS 15.0、iPadOS 15.0、Mac Catalyst 15.0、visionOS 1.0)是一个请求,不是一个设定。15 该页的建议是 “Choose a frame rate range that your app can consistently maintain”(选择一个您的 app 能稳定维持的帧率范围),并描述了系统如何处理这个请求:“The system typically provides a consistent frame rate by choosing one that’s a factor of the display’s maximum refresh rate.”(系统通常会选择显示屏最高刷新率的一个约数,以提供稳定的帧率。)默认情况下,范围等于显示屏的最大值。15 范围本身是一个 CAFrameRateRange(iOS 15.0),由最小、最大和首选速率组成。47
Apple 关于 ProMotion 的文章给出了数字。ProMotion 显示屏在 iPad Pro 上于 24 到 120 赫兹之间切换,在受支持的 iPhone 上于 10 到 120 赫兹之间切换,iPhone 的速率分十二档:120、80、60、48、40、30、24、20、16、15、12 和 10 赫兹;iPad Pro 是其中的五档。7 设备列表如今在 “iPhone 13 Pro and later”(iPhone 13 Pro 及更新机型)之外,还列出了 iPhone Air 和 “iPhone 17 and later”(iPhone 17 及更新机型)。7 在 iPhone 上,除非 app 的 Info.plist 把 CADisableMinimumFrameDurationOnPhone(iOS 15.0)设为 true,否则不会超过 60:“If you don’t enable this support, Core Animation won’t access higher frame rates (above 60Hz).”(如果不启用这项支持,Core Animation 不会使用更高的帧率,即 60Hz 以上。)716 文章里有两句话对游戏而言比其余部分更重要。一句是:“In iOS 15 and later, the system provides games with special priority to 30Hz and 60Hz refresh rates to ensure optimal performance”(在 iOS 15 及更高版本中,系统为游戏的 30Hz 和 60Hz 刷新率提供特别优先级,以确保最佳性能),通过 CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60) 获得。7 另一句是:“Prepare your app to operate at any refresh rate, not just those it requests.”(让您的 app 能在任何刷新率下运行,而不只是它请求的那些。)7 对任何会动的东西则是:“Always use targetTimestamp to drive any animation, physics, or other time-related content provided in your CADisplayLink callback”(始终用 targetTimestamp 驱动您在 CADisplayLink 回调中提供的动画、物理或其他与时间相关的内容)(targetTimestamp 为 iOS 10.0)。748
WWDC21 讲座 “Optimize for variable refresh rate displays”(为可变刷新率显示屏优化)同时讲了 iPad Pro 上的 ProMotion 和 Mac 上的 Adaptive-Sync 显示屏。49 对 Mac 的 Adaptive-Sync 显示屏,它改变了 Apple 早先的建议:在固定刷新率的显示屏上,“we’ve previously recommended that you slow down your rendering to hit the next factor of the display’s fastest refresh rate”(我们以前建议您放慢渲染,去对上显示屏最高刷新率的下一个约数);在 Adaptive-Sync 上,“You should instead attempt to present frames at the highest rate your app can do so evenly.”(您应当改为尝试以您的 app 能够均匀做到的最高速率呈现帧。)49 我从中为手机带走的词是 evenly(均匀地)。
RealityKit 的时钟
Kiradex 的世界没有自己的显示链接。它在 RealityKit 的逐帧事件 SceneEvents.Update(iOS 13.0)上推进,即 “An event invoked once per frame interval”(每个帧间隔调用一次的事件),其 deltaTime 是 “The elapsed time since the last update.”(自上次更新以来经过的时间。)5051 RealityView(iOS 18.0)为逐帧工作记载的正是这条路径,“you can use a System or directly subscribe to the engine’s SceneEvents.Update”(您可以使用 System,或直接订阅引擎的 SceneEvents.Update),它自身不提供帧率设置。52 Apple 关于 RealityKit 性能的文章说明了该预期什么速率:“RealityKit typically limits the refresh rate”(RealityKit 通常会限制刷新率),它把刷新率定义为框架为屏幕渲染更新的速率,接着说 “to 60 frames per second (fps).”(到每秒 60 帧。)8 iPhone 18 Pro Max 或 iPhone Duo 上的 RealityView 实际以 60 还是 120 渲染,我没有测量过,第 7 节把这项测量定为简报的第一项检查。
为什么 120 赫兹对整像素行走毫无帮助
下面是算术,用的是第 7 节对应用当前代码所用的同一个模型脚本,这次套用在提议上:一个按固定 60 赫兹拍推进、每拍移动 1 像素的世界,显示屏显示最近一拍产生的结果。在 60 赫兹显示屏上,每个世界像素位置都恰好显示一次刷新。6 在 120 赫兹显示屏上,一秒钟里 61 个位置中有 59 个恰好保持两次刷新,2 个保持一次。6 在 80 赫兹显示屏上,保持时间在一次和两次刷新之间交替,42 个位置保持一次,19 个保持两次。6 依我的解读,120 赫兹的情况就是把 60 赫兹的情况每张画两遍,而 80 赫兹的情况是不均匀的节奏:一个像素有时停留 12.5 毫秒,有时停留 25 毫秒。
所以对 iPhone 上的网格世界来说,问题不是 120 赫兹,而是在 60 下均匀地推进:固定 60 赫兹的模拟拍,每拍整数位移,以拍计数的动画,以及显示最近一拍的渲染器。这样一来,不管 RealityKit 选择什么速率,运动都不受它左右。WWDC21 讲座谈到时间差时提出了相关的一点。当一个慢帧让显示链接跳过一次回调时,该推进的时间差是 “not 8ms, but rather 16ms”(不是 8ms,而是 16ms);讲座接着说,一个 “uses time delta to advance the state of your custom drawing”(用时间差推进自定义绘制状态)的 app,每跳过一次回调就会 “slow down your custom drawing by one frame”(让自定义绘制慢一帧),我把这理解为按预期的 8 毫秒而不是实际经过的时间推进的 app;讲座还说,app “can instead keep track of a previous targetTimestamp so that you can advance the state correctly.”(可以改为记录上一个 targetTimestamp,以便正确推进状态。)49
触感反馈
Core Haptics(iOS 13.0、iPadOS 13.0、Mac Catalyst 13.0、visionOS 1.0)从字典、CHHapticEvent 对象数组或 AHAP 文件构建 CHHapticPattern,即 “An object representing a haptic waveform”(表示一段触感波形的对象)。53 事件有两种触感类型,hapticTransient 和 hapticContinuous;瞬时型是 “brief impulses that occur at a specific point in time.”(在特定时间点发生的短促脉冲。)54 每个事件接受参数 hapticIntensity、hapticSharpness、attackTime、decayTime、releaseTime 和 sustained。55 由 CHHapticEngine 播放,capabilitiesForHardware() 告诉您设备能否播放。56
更简单的途径是 UIImpactFeedbackGenerator(iOS 10.0),“A concrete feedback generator subclass that creates haptics to simulate physical impacts”(一个创建触感来模拟物理撞击的具体反馈生成器子类),它的样式描述的是 “The mass of the objects in the collision”(碰撞中物体的质量)(light、medium、heavy、soft、rigid);impactOccurred(intensity:) 为 iOS 13.0,该页把 init(style:view:) 列在 “Initializing the feedback generator”(初始化反馈生成器)下,把 init(style:) 列在 Deprecated(已弃用)下。575859 prepare() 只有在有时间生效时才能降低延迟:“Calling prepare() and then immediately triggering feedback (without any time in between) does not improve latency”(调用 prepare() 后立即触发反馈、中间没有任何时间间隔,并不会改善延迟),而引擎会在 “A short period of time passes (typically seconds).”(经过一小段时间,通常是几秒)之后回到空闲状态。60 在 SwiftUI 中,sensoryFeedback(_:trigger:)(iOS 17.0)“Plays the specified feedback when the provided trigger value changes”(在所提供的 trigger 值变化时播放指定的 feedback),其中包括 .impact(weight:intensity:)。61
HIG 关于播放触感反馈的页面是设计的那一半。“Avoid overusing haptics”(避免过度使用触感反馈),46 理由是:“Often, the best haptic experience is one that people may not be conscious of, but miss when it’s turned off.”(最好的触感体验,往往是人们意识不到、但关掉后又会怀念的那种。)“Make haptics optional.”(让触感反馈成为可选项。)把 “the intensity and sharpness of a haptic with the intensity and sharpness of the animation it accompanies.”(触感的强度和锐度,与其所伴随动画的强度和锐度)相匹配。46 锐度可以传达一种 “that’s soft, rounded, or organic, or one that’s crisp, precise, or mechanical.”(柔和、圆润或有机的体验,或清脆、精确或机械的体验。)46 自定义触感能让 “a collision or a hit”(一次碰撞或一次击打)与 “from subtle experiences like the approach of footsteps or a looming danger.”(脚步渐近或危险逼近这类细微体验)感觉截然不同。46 每秒 3.75 步、一走就是几分钟的行走,正是第一条规则所说的情形。1
输入
用第 4 节的术语来说,触控操控的 API 如下。Touch Controller(iOS 26.0、iPadOS 26.0、Mac Catalyst 26.0、visionOS 26.0)在其页面上被概括为 “Integrate onscreen touch controls into your Metal-based games”(把屏幕触控操控集成到您基于 Metal 的游戏中):按钮、方向键、摇杆、油门和触控板,通过一个 GCController 提供。62 它的 TCDirectionPad(iOS 26.0、iPadOS 26.0、Mac Catalyst 26.0)可以配置为 “to behave as either a composite direction pad”(表现为一个组合方向键)或 “as four separate buttons.”(表现为四个独立按钮。)6263 更早的 GCVirtualController(iOS 15.0)是 “A software emulation of a real controller that you configure specifically for your game.”(一个您专门为自己的游戏配置的、对真实手柄的软件模拟。)64 我试过的一个地址 developer.apple.com/documentation/touchcontrols 返回 404;该框架的页面是 touchcontroller。39
代价
本节的内容都没有在手机上计时。设备上世界的帧时长、60 赫兹累加器的开销,以及在世界显示期间让触感引擎持续运行的开销,都没有测量;第 7 节简报中的检查就是为测量前两项而写的。
7. 简报:Kiradex 要做什么,以及必须通过的检查
行走的现状
2026 年 10 月 4 日,我在不改动任何内容的情况下阅读了 Kiradex 仓库里世界的行走、相机和过渡代码,并为它写了一个逐帧模型。本小节的一切要么读自那段代码,要么由模型算出,并注明是哪一种。模型按代码的顺序、用 Swift 的取整方式、以每帧恰好 1/60 或 1/120 秒,把代码的每个 Float 运算都按 32 位执行;它略去了地图边缘、iPhone Duo 的折叠和脚底偏移,这些都不会改变在地图中央沿直线的行走。它是代码的模型,不是录屏,也没有在任何手机上测量过。176
从代码中读出的代码行为:
- 输入只有点按行走。 舞台把一次点按转换为世界坐标,然后要么当作点了某个收藏者或售货亭,要么调用
walk(to:)。没有拖动,没有方向键,应用 target 中任何地方都没有触感调用:搜索UIImpactFeedbackGenerator、sensoryFeedback和CHHaptic一无所获。17 - 寻路是八方向最短路径搜索,按已走的代价排序,不估计剩余距离,所以它是 Dijkstra 搜索而不是 A*;对角线代价为 √2,且从不切过墙角。对角一步朝向侧面,并在乘上跑步的 1.6 之后再把进度除以 √2,所以在模型里,60 赫兹下对角一步行走要 22 帧、跑步要 14 帧,而直行一步是 15 帧和 10 帧。176
- 速度就是时间。
tilesPerSecond为 4,即每秒 64 像素;当路径为 6 步或更多时,行走变成 1.6 倍速的跑步,所以应用里的行走是 1 到 5 格,更长的都用跑。每帧给一步的进度加上dt × speed / length,当进度达到 1 时,行走者吸附到下一格,进度归零,超出的部分被丢弃。17 - 行走画面按时间选择。 列是
cycle[Int(walker.clock * framesPerSecond) % count],framesPerSecond为 8,循环是锻炉画出的六张画面的行走和跑步循环。本系列角色篇报告过同样的节奏,“the walk at eight frames a second”(每秒八帧的行走),这准确描述了代码。1735 - 相机先缓动,再取整。 每帧它朝玩家移动
(target - current) * min(1, dt * 6),然后把两个轴都取整到整数单位;当地图比视野大时,它会被夹在地图范围内。第一篇里那句话,说每个 sprite 和相机都 “are rounded to whole world units each frame, after easing”(在缓动之后,每帧取整到整数世界单位),同样准确。1722 - 门与传送。 门立刻显示图集里的半开画面,70 毫秒后显示打开的画面,1.4 秒后移除门;踏上传送点的那一步等待 220 毫秒后切换地点。建筑篇把这一点列为已知的缺口。1713
- 过渡是导航 push,用的是默认动画;换楼层是按 identity 替换舞台,没有淡出;离开则调用
dismiss()。应用里唯一的蒙版是卡牌查看器的黑色覆盖层,用.easeOut(duration: 0.25)做动画。17 - 没有帧率请求。 应用及其项目文件里都没有
CADisplayLink、preferredFrameRateRange或CADisableMinimumFrameDurationOnPhone;世界在SceneEvents.Update上用事件的deltaTime推进。17
在稳定帧时长下,模型对这段代码在屏幕上的表现给出的结果:
- 60 赫兹下每格一次 2 像素的跳顿。 60 赫兹下行走每格 15 帧,每秒 4.00 格,每格的 16 像素由 14 帧 1 像素和 1 帧 2 像素组成:每格一次 2 像素的跳跃,走五格就是五次。《绿宝石》每一帧都恰好移动 1 像素。61
- 120 赫兹下不规则的 0 和 1。 120 赫兹下行走每格 30 帧,每格的 16 像素由 16 帧 1 像素和 14 帧 0 像素不规则地交错组成。6
- 跑步比它的常量慢。 60 赫兹下跑步每格 10 帧,每秒 6.00 格,而 4 乘以 1.6 应是 6.4,原因是每格超出的部分都被丢弃了;每格的 16 像素由 4 帧 1 像素和 6 帧 2 像素组成。120 赫兹下每格 19 帧,每秒 6.32 格,所以跑步速度取决于帧率。6
- 一个拖在后面、永远追不上的相机。 60 赫兹行走时,相机每走一格就多落后约 1 像素,第一格结束时 5 像素,到第五格结束时 9 像素,那是应用里最长的一次行走;玩家在屏幕上的位置,在第一帧之后的 73 帧里有 8 帧发生变化。跑步,也就是 6 格及以上的所有路径,从第二格起落后 13 像素。120 赫兹下,无论走还是跑,都在第一格之内就到 9,然后保持不变。玩家停下后,相机在 60 赫兹下停在离玩家 4 像素处,120 赫兹下 9 像素,并就此停住:一旦差距乘以
min(1, dt × 6)小于半个像素,取整每帧都会返回同一个位置。停在哪一侧,取决于最后一次行走的方向。《绿宝石》的拖尾和静止偏移都是零。6 - 画面自有它的时钟。 每秒 8 张的六张画面循环持续 0.75 秒。以 60 赫兹下相邻两次循环开始之间的模型位置来量,它行走覆盖 3.00 格,跑步覆盖 4.50 格(按跑步名义上的每秒 6.4 格应为 4.80,但被丢弃的超出部分让它永远达不到);《绿宝石》两次落脚的循环覆盖 2.00 格。依我的解读,一个循环覆盖的地面比它的落脚次数多出一半,看起来就是脚在打滑,但模型并不测量脚落在地面的何处。在 60 和 120 赫兹下,每隔 15 帧画面还会处在刀刃上:时钟乘以 8 恰好落在整数上,也就是两张画面的分界处;像应用那样用 32 位而不是 64 位保存时钟,会让 60 赫兹下走 12 格时的 11 个这样的帧里有 9 个显示不同的画面,120 赫兹下则是 23 个中的 14 个。6
- 一步之中第二次点按,会把行走者拽回去。 这一点读自代码,既不是建模也不是录屏:
walk(to:)在行走者所在图块仍是这一步起点时把进度设为 0,所以下一帧画出的行走者会比原来靠后最多 15 像素;来自服务器的其他收藏者的移动也一样。17
每个显示帧的像素数:经典是均匀的,今天的代码(模型,不是录屏)不均匀,而固定的拍在 120 下也是均匀的。621
按模型看,缓动再取整的相机:行走或跑步时拖在后面,行走结束时停在差一点的地方。621
这些都不是对手感的评判,因为还没有在手机上测过。真实的帧时长会抖动,这会改变 1 和 2 的确切排布。拖尾和静止偏移也取决于帧时长,因为缓动步长 min(1, dt × 6) 取决于它,这就是模型给出 60 赫兹下静止 4 像素、120 赫兹下 9 像素的原因。依我的解读,只要抖动在手机通常的范围内,它会改变这些量的大小,而不会让它们消失;条件是帧保持短于 1/6 秒,约 167 毫秒,因为到了这个长度,缓动系数达到 1,相机会在一帧之内落到玩家身上,所以一次足够长的卡顿会在那一帧把差距抹平。176 画面与行走之间的错位与帧率无关:每秒 8 张的画面时钟对上每秒 4 格的行走,60 赫兹下每个循环 3.00 格,120 赫兹下也差不多。跑步的错位则与帧率有关,60 赫兹下每个循环 4.50 格,120 赫兹下 4.75 格,因为它在每格丢弃的超出部分与帧率有关。6
简报
每一项都包括对 Kiradex/World/ 的一处修改、修改的理由,以及一个可以用一行日志、一个脚本或一段录屏来验证的检查。不提议使用任何游戏的素材、名称或声音:数字是机制,而美术、声音和文字都是 Kiradex 自己的。
1. 按拍行走,而不是按时钟行走。
修改。 在 rig 的更新中加入一个 60 赫兹的累加器。每次回调加上它的 dt;如果此时累加器里的时间超过 8 拍(133 毫秒),就丢弃超出部分,并以一行 dropped 把它的毫秒数写入运动日志;然后运行累加器里的每一个完整拍,每运行一拍减去一拍的时间。累加器以整数单位(纳秒,或模型里的 1/60,000,000 秒)计时,绝不用浮点数的秒:如果用 Double 或 Float 累加器,十秒测试中的第 600 拍在某些速率下会因为一次舍入误差差一点够不着它的边界,数出来就成了 599。这就是积压规则:至多 8 拍的延迟会在下一次回调中补上,超过的部分视为一次挂起,于是世界从停下的地方继续,而不是飞快地追赶。选 8,是为了覆盖 Apple 为 ProMotion iPhone 列出的每一种速率,一直到每次回调需要 6 拍的 10 赫兹。7 在模型里,十二种速率各跑十秒的回调,都恰好运行 600 拍,一拍也不丢弃;而若每次回调上限为 4 拍,12 赫兹下只会运行 480 拍,10 赫兹下只有 400 拍。6 在 60 赫兹下出现一次一秒的卡顿后,这条规则在下一次回调运行 8 拍,之后每次回调 1 拍,并记录丢弃了 866.7 毫秒;同样的卡顿,如果保留积压、只设每次 4 拍的上限,就会连续 19 次回调每次运行 4 拍,这正是这条规则要防止的快进。6 每个行走者保存一步的拍数,而不是一个小数进度,它画出来的偏移就是拍数乘以每拍像素数:都是精确的整数,所以行走者不再需要取整。行走是每拍 1 像素、每格 16 拍;跑步是每拍 2 像素、8 拍,并保留路径 6 步及以上即为跑步的现行规则。寻路允许的对角步,保留代码本来就打算的 √2 及其先后顺序,即先乘跑步节奏、再除以 √2:行走对角一步 23 拍(16√2 约为 22.6,向上取整),跑步对角一步 12 拍(8√2 约为 11.3,向上取整),分别在 floor(16 × t / 23) 或 floor(16 × t / 12) 变化的拍上移动,每个轴各走 16 像素。17 行走时,23 拍中有 16 拍移动 1 像素,其余 7 拍不动;跑步时,12 拍中有 8 拍移动 1 像素、4 拍移动 2 像素。这是简报中唯一不均匀的步态,正如杂技自行车是《绿宝石》中唯一不均匀的步态。1 没有需要丢弃的超出部分。
理由。 第 5 节中行走和跑步的规则;以及模型显示的 60 赫兹下每格一次 2 像素跳顿和 120 赫兹下不规则的 0 与 1。61
检查。 启动参数 -motionLog 在每一拍记录拍号和玩家的 x, y,以及每一行 dropped。在现有的朝向演示中(它朝每个方向各走一格),由脚本断言:每个行走拍恰好移动 1 像素,每格用 16 拍,没有任何一拍移动 2 像素。一个对角演示,朝每个方向各走一步行走对角和一步跑步对角,断言 23 拍和 12 拍、每步每个轴 16 像素、每轴移动行走时为 0 或 1 像素、跑步时为 1 或 2 像素,并且跑步对角比行走对角更快。单元测试以精确的整数单位向累加器输入人为构造的 dt 序列:从 120 到 10 赫兹的十二种 iPhone 速率各跑十秒,都运行 600 拍且不丢弃任何东西;60 赫兹下一秒的空档,在下一次回调运行 8 拍,之后每次回调 1 拍,并记录丢弃 866.7 毫秒。同样的运动日志在 iPhone 18 Pro Max 和 iPhone Duo 内屏上给出相同的每格拍数:显示屏的速率不得改变行走。
2. 按距离选择行走画面。
修改。 这一项是我的提议,不是照搬:《绿宝石》以帧为画面计时,与步子相匹配,并不从距离读取画面。92 行走时,按以步数计的行走进度选择列,即行走开始以来已完成的步数,加上当前这一步的拍数除以其长度:walkCycle[floor(steps × walkCycle.count / 2) % walkCycle.count],两步落脚两次,跑步同理。在直行的一步上,这就是行走距离除以 32 像素,即 floor(distancePx × 6 / 32) mod 6。在对角上,它数的是步,而不是 √2 倍的长度,所以每一步开始时仍会落脚,只是步幅更长;这是我为按直行步幅画的画面所做的选择。行走结束时显示站立画面,与《绿宝石》一样。待机、眨眼和表情的时钟保持原样。按拍计时的循环也可以像《绿宝石》那样与步子对上,但六张画面分摊 32 拍,需要 5 拍和 6 拍这样不均匀的保持时间;按进度计数则不需要第二张表,而且对应用今后加入的任何步态都成立。
理由。 任何速度下每 16 像素落脚一次,以及一个不可能偏离运动的节奏;在模型里,现在的循环行走覆盖 3.00 格、跑步覆盖 4.50 格。16 这也了结了角色篇里的 “eight frames a second”(每秒八帧):每拍 1 像素时,六张画面分摊 32 像素,每 5.33 像素换一张,即每秒 11.25 张,而不是 8 张。35
检查。 根据运动日志加上所显示的列:两张着地画面(锻炉循环中的 walk_a 和 walk_d)在 60 和 120 赫兹下都出现在每 32 像素中的 0 和 16 像素处,允许正负一像素;在对角演示中,它们出现在每一步的第一拍。
3. 走完这一步;保留点按;加一个浮动摇杆。
修改。 一步之中的点按,从正在前往的那一格而不是起点开始规划路径,并先走完当前这一步;步数绝不重置。来自服务器的其他收藏者的移动也适用同样的规则。站立时,如果一次点按的第一步会改变朝向,就先用 8 拍转身再移动。点按玩家旁边一个被挡住的格子,或玩家面前的图块,会让玩家转向它,并播放一段 32 拍的碰撞,同时伴随第 6 项的拒绝触感。手指按住不放会跟随它,与星露谷的默认方式一样,但每当手指越过一个图块,就用现有的寻路重新规划路线,因为星露谷的跟随不会绕开障碍,而我们的可以。一个四向的浮动摇杆,默认关闭,出现在拇指落下的地方,用叠在世界上方的 SwiftUI 拖动手势实现;任何画出来的东西都至少 44×44 点。只有当测试构建显示 Touch Controller 的摇杆(iOS 26)能画在 RealityView 之上时,才用它替代拖动:Apple 把该框架描述为面向 “Metal-based games”(基于 Metal 的游戏),WWDC25 讲座说它 “integrates directly with Metal”(直接与 Metal 集成),而 Kiradex 的世界由 RealityView 绘制,没有自己的渲染通道。4562
理由。 第 5 节中关于一步、转身、被拒绝的一步和输入的规则。14044624517
检查。 一个 UI 测试点按东边第 6 格,120 毫秒后再点按北边第 6 格;运动日志中没有任何一拍玩家的 x 减小,并且在转身之前完成第一步所在的那一格。一个 UI 测试点按房子旁边的墙;日志显示一次朝向变化和一段 32 拍的碰撞,位置没有改变。打开摇杆后,从移动的第一拍起向右推住 60 拍,到第 48 拍完成三格,在摇杆仍被推住时开始第四格,并在松开后的第 64 拍完成它:日志显示玩家向东 4 格,停在整格上,松开时正在进行的一步已经完成,格与格之间没有停顿。
4. 锁定相机。
修改。 用一个与玩家移动同一拍设定、只由整数世界像素构成的相机取代缓动:camera = clamp(player + foldOffset, low, high)。之所以每一项都保持整数,是因为如今只有最后那次取整让相机成为整数:边界和折叠偏移都带小数。玩家位置按第 1 项已是整数。折叠偏移由 follow 以点数乘以 displayScale / pixelScale 计算,在折叠变化时一次性取整到整数世界像素。夹取边界向内取整,下界向上、上界向下,因为以世界像素计的视野半宽未必是整数:fit 用视野的屏幕像素除以每个纹素对应的整数个屏幕像素,所以一个示意性的 3 倍 393×852 点视野得到 7,视野宽 168.43 个世界像素,半宽 84.21,相机的边界就成为 85 和地图宽度减 85,于是没有任何一帧会露出地图边缘之外。176 当地图比视野小时,边界会互换,就像现在的夹取所允许的那样,同样的向内取整让整张地图留在视野里。保留对地图的夹取;第 5 节的规则允许夹取或画出外面,而 Kiradex 的相机只有在 Duo 半开时,才会越过地图边缘、以整数格的余量进入树林。17 在折叠改变之前把折叠偏移视为固定;当它从 a 变为 b 时,让 i 从 0 到 8,依次走过 a + round((b − a) × i / 8),每 2 拍一档,即淡出的节奏,这样途中每个位置都是整像素;例如从 0 变到负 37,会经过 0、−5、−9、−14、−19、−23、−28、−32 和 −37。6 保留树林的视差,由锁定的相机计算,并像现在一样取整。不做朝点按目的地的前瞻:Game Freak 做过一个并把它关掉了,而点按行走的目的地本来就已在屏幕上。12
理由。 第 5 节的相机规则;以及模型中的拖尾,它在行走中逐渐增长,到第五格达到 9 像素,跑步时为 13,由此引起的玩家在屏幕上位置的变化,以及 4 像素和 9 像素的静止偏移。6
检查。 根据运动日志,在开阔的广场上行走时,每一拍相机减去玩家的值都恒定(为零,或为折叠偏移),玩家停下后也一样,且每个相机值都是整数。一个单元测试以 3 倍 393×852 点的视野调用 fit,让玩家走到地图两侧边缘,断言相机位置是整数,并停在 85 和地图宽度减 85;同一个测试接着改变折叠,断言相机经过 9 个相隔 2 拍的整像素位置到达新偏移,且其间没有任何一拍越出边界。若用录屏检查,行走画面不能当标记,因为脚的形状随画面而变;由调试构建在玩家实体的位置画一个单纹素标记,颜色是别处都不使用的,再由脚本在开阔广场上一次 6 格行走的每一帧中找到它:每一帧都在同一个屏幕像素上。在地图边缘附近,标记移动而相机不动,且只按整像素移动。
5. 门、那一步和淡出,用 Kiradex 自己的美术。
修改。 进门,即带门图集的传送点。如今门是在行走者落到门格之后才打开的:这一步的 onStep 在那一格发现传送点,就地调用 openDoor。17 《绿宝石》从不让玩家站在一扇关着的门上:TryDoorWarp 只在玩家位于下方格子、朝北推向一扇门时触发,而 Task_DoDoorWarp 先打开上方一格的门,再强制走上去。6525 简报据此把触发点往回挪一格:
- 当路径的下一步是踏上门的传送点时,行走在它前面的那一格停下,进门流程从那里、在第 0 拍开始。冻结输入并播放门的音效,用的是 Kiradex 的音效。
- 用四个各 5 拍的时段开门,即《绿宝石》四张画面、每张五帧的做法(每秒 60 拍时每个时段 83 毫秒,而《绿宝石》的五帧是 84 毫秒;共 20 拍),取代现在 “立刻显示半开、70 毫秒后显示全开” 的做法:关、半开、开、开。Kiradex 的门图集有三张画面,关、半开和开(锻炉的
door_sheet()画三张,应用以SpriteSheet.bundled(name, columns: 3, rows: 1)载入),而《绿宝石》的门是一张关着的加三张开着的,所以第四个时段继续显示打开的画面。如果锻炉在半开和全开之间画出第四张,就由它来填那个时段;这是可选的美术,不是硬性要求。修正那条写着 “four ticks”(四拍)的注释。2417 - 让玩家从那一格强制走一步到门格,16 拍。镇上的门都在建筑的最底一排,左右和上方都是建筑的格子,且路径从不切过墙角,所以这一步总是从下方格子向上走,与《绿宝石》一样。17
- 隐藏行走者,按相反顺序(开、开、半开、关)走过各个时段把门关上,20 拍。
- 在世界上方以 9 个台阶式的不透明度档位(0、2/16,依此类推直到 16/16)淡入一层黑色蒙版,档位
i落在淡出的第2i拍,所以最后一档落在第 16 拍并保持到第 17 拍:18 拍。这是有意选择的改编,不是《绿宝石》的时序。《绿宝石》的 sprite 调色板在与这层蒙版相同的偶数帧到达各档,背景调色板早一帧到达,并在第 16 帧最后一次混合之后再跑五次收尾更新,到第 21 帧才变为非活动;一层蒙版既没有会滞后的第二层,也没有需要收尾的东西。5 永远不要给承载 RealityView 本身的视图做不透明度动画;第一篇已经从卡牌查看器那里吸取过这个教训。22 - 关闭动画推入目标页面,再以同样的 9 个档位把蒙版淡出。
- 面朝上出现在室内的地垫上。离开时反过来:出现在门格上、门开着,向下强制走一步 16 拍,门关上,然后接受输入。
楼层切换现在是不淡出地替换舞台,今后使用同样的蒙版,但没有门,也没有强制的一步。离开房间现在是调用 dismiss(),今后使用蒙版和向外的强制一步。
理由。 第 5 节中关于门、淡出和仪式的规则;如今地点在那一步之后 220 毫秒切换,门显示两张相隔 70 毫秒的画面,屏幕还会横向滑动。1517
检查。 运动日志从行走在门下方停下的那一拍开始计拍。它显示玩家仍在那一格上,门的各时段在第 0、5、10 和 15 拍(关、半开、开、开);强制的一步在第 20 到 35 拍,每拍 1 像素,在第 35 拍到达门格,不会更早;关门的各时段在第 36、41、46 和 51 拍;蒙版的第一档在第 56 拍;推入页面不早于第 74 拍。一次没有经过这个流程就停在门格上的行走,判为检查失败。运动日志也在每一拍记录蒙版的不透明度:离开时在第 56 到 73 拍有 9 个不同的值,每个保持 2 拍,进入时是同样的 9 个。录屏要在画面中静止的部分上确认:整帧不行,因为在有水的地图上,地面的水每秒换 4 次画面,其他收藏者也可能在走动,所以脚本采样一块事先选好的建筑墙面,那里没有动画图块,录屏中也没有行走者经过,数出离开时 9 个、进入时 9 个不同的档位,每个保持 2 帧,60 赫兹下允许正负一帧。17 在一次传送的录屏中,世界从不水平平移:没有导航滑动。
6. 触感反馈:三个事件和一个开关。
修改。 由世界持有一个 CHHapticEngine,在世界出现时启动,消失时停止;设置里有一个默认开启的触感开关,并遵从系统的触感设置。各个图案以 AHAP 文件的形式放在 bundle 里:
| 事件 | 图案 | 理由 |
|---|---|---|
| 被拒绝的一步(碰撞) | 一个 hapticTransient,强度 0.4,锐度 0.2 |
HIG 中的 impact 是 “a thud when two heavy objects collide”(两个重物相撞时的闷响)46;美术是柔和的,所以要柔和 |
| 门打开 | 在开门过程中画面发生变化的每个时段各一个 hapticTransient,强度 0.3、锐度 0.6,即第 5 拍的半开画面和第 10 拍的全开画面(83 和 167 ms) |
与它所伴随的动画相匹配 |
| 举起卡牌(3D 卡牌) | 到达顶端时一个 0.7 和 0.8 的 hapticTransient |
世界中唯一的真实物体 |
| 脚步 | 默认没有 | 每秒 3.75 步、持续几分钟,正是 HIG 警告的过度使用 |
强度和锐度的数值是我的起点,不是测量结果,是供在设备上调校用的。当 capabilitiesForHardware() 表明 Core Haptics 不可用时,退而使用 UIImpactFeedbackGenerator(style: .soft, view:),并在一次行走开始时调用 prepare()。
理由。 触感反馈的规则;第 6 节引用的 Apple 指南。46565760
检查。 一个 -hapticLog 参数记录每个事件。一次脚本化的进门,按第 5 项的计拍,在第 5 和第 10 拍恰好记录 2 个门事件,每一步都没有事件;开关关闭时,一个也没有。
7. 帧率:60,且均匀。
修改。 Info.plist 不做任何修改:不要添加 CADisableMinimumFrameDurationOnPhone,因为世界从 120 中得不到任何好处。保留 RealityView;第 1 项的拍让运动不受它以什么速率渲染的影响。如果将来加入显示链接,为了获得 Apple 的游戏优先级,设置 CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)。76
检查。 在 iPhone 18 Pro Max 以及 iPhone Duo 的外屏和内屏上,记录一次 60 秒行走期间 SceneEvents.Update.deltaTime 的直方图。简报假设众数是 16.7 毫秒;如果是 8.3,第 1 项的累加器仍会把行走保持在每格 16 拍,日志可以证明这一点。运动日志中的 dropped 行:正常行走时,在两种速率下都没有。
8. 震动,只留给一个时刻。
修改。 一次以整纹素计的相机震动,1 个纹素,翻转 8 次,间隔 5 拍,只用于一个值得它的事件,例如在广场上揭晓一张稀有卡牌,并配合举起卡牌的触感。不用于门、脚步或抵达。
理由。 《绿宝石》24 次震动中最常见的那一种,以及它们的克制。29
检查。 运动日志显示相机偏移在第 0、5 拍,依此类推直到第 35 拍,在正负 1 个纹素之间交替,并在第 40 拍回到 0。
不在本简报之内
- 网格上的相机平滑或前瞻。 没有需要抹平的跳跃,而 Game Freak 出货时把它的前瞻关掉了。1223
- 为世界启用 120 赫兹。 目标是在 60 下均匀推进;120 只是把每个像素显示两次。6
- 默认每一步都有触感反馈。46
- 固定在屏幕上的方向键。 我的建议是点按行走加一个可选的浮动摇杆。44
- 任何游戏的开门或传送音效与配乐。 声音是表达,Kiradex 需要自己的声音。
- 自行车、冲浪、冰面和水流。 应用里的行走者只会走和跑,上面的速度先记录下来,留待以后有变化时使用。17
悬而未决的问题:设备上的时序
本节的每个模型数字,都假设帧与帧之间是稳定的 1/60 或 1/120 秒。iPhone 18 Pro Max 或 iPhone Duo 上的 RealityView 以 60 还是 120 渲染,它的 deltaTime 抖动有多大,都还没有测量;第 7 项的直方图是要最先运行的,而第 1 项的写法,保证无论答案是 Apple iPhone 列表上的哪个速率,行走都不会有任何改变。687
核心要点
如果您负责画美术
- 按一段距离来画行走。《绿宝石》的行走是在 32 像素内迈两步、站两次,按与步子相匹配的帧数保持;更快的步态复用这些画面、缩短保持时间,而不是增加帧,所以在任何速度下,每 16 像素都落脚一次。192
- 六张画面的循环没有问题,前提是引擎在固定的距离内播放它;如果按固定速率播放、而行走另行计时,它的节奏就会偏离运动,在我们代码的模型里,行走每个循环 3.00 格,跑步 4.50 格。6
- 《绿宝石》的门是一张关着的加三张画面,每张在屏幕上停留 84 毫秒。像 Kiradex 这样关、半开、开三张的图集,可以让打开的画面保持两个时段来填满四个时段,或者由锻炉画出第四张;不论哪种,都要把打开的状态画成值得一看的样子。12417
如果您负责做引擎
- 用固定的拍推进世界,每次移动整像素且能整除图块,在图块上读取输入,让渲染器显示最近的一拍。写明积压怎么处理:我们的做法是补上至多 8 拍,其余丢弃并记录。《绿宝石》的步进表就是范本:每一张加起来都是 16。216
- 在同一拍把相机锁定在玩家身上,边界和偏移都用整像素,这样事后就不需要取整。先缓动再取整会拖在行走的玩家身后,而且在我们代码的模型里,会在下一次行走之前一直停在差 4 到 9 像素的地方。36
- 在 iPhone 上,不要为像素运动请求 120 赫兹。Apple 为游戏优先保证 30 和 60,RealityKit 通常以 60 渲染,而 120 下的整像素行走只会让每个像素保持两次刷新。786
- 淡出要分档,不要做成平滑斜坡。《绿宝石》的门淡出是九个相隔十六分之二的档位,最后一次混合在第 17 帧,淡出在第 22 帧结束;我们 18 拍的蒙版是那道斜坡的改编,不是照搬。5
- 永远不要给承载 RealityView 的 SwiftUI 视图做不透明度动画;在它上面盖一层蒙版。22
如果您负责设计游戏循环
- 在《绿宝石》里,进门是一场至少 1.32 秒、不接受输入的仪式,而跨过门槛的强制一步,正是让它读起来像 “走进去” 的关键。525
- 原地转身 8 帧,让玩家不移动就能面向某样东西;一段 32 帧的碰撞告诉他们这一步被拒绝了。1
- 在手机上,我的建议是:以点按行走为默认,就像星露谷移动版出货时那样;以浮动摇杆作为精确操作的后备,就像 HIG 所建议的;不设固定方向键。4044
- 触感反馈用来确认事件,而不是脚步,并且要配一个开关。46
常见问题
宝可梦里玩家走得有多快?
在《红》《水晶》《绿宝石》里,行走的一步以每秒 59.7275 帧、用 16 帧走完一个 16 像素的格子:每格 268 毫秒,每秒 3.73 格。《绿宝石》每帧移动 1 像素;《红》和《水晶》每隔一帧移动 2 像素。《绿宝石》的跑步和冲浪,以及《红》和《水晶》的自行车,每格 8 帧,每秒 7.47 格。1419
《宝可梦 绿宝石》的行走循环有多少帧?
32 帧里的四个条目:一张迈步画面 8 帧,站立画面 8 帧,另一张迈步 8 帧,站立 8 帧。这相当于走两格,所以每一步显示一次迈步和一次站立,双腿逐步交替。跑步是覆盖两个 8 帧格子的 16 帧循环。192
《宝可梦 绿宝石》的相机会落后于玩家吗?
不会。《绿宝石》的相机复制玩家的位置,在同一帧把地图滚动同样的像素,所以世界移动时玩家在屏幕上保持不动。它在地图边缘也不会停下:外面用布局的边界图块画出来。代码里有一个自行车用的前瞻相机,但从未开启。231112
《宝可梦 绿宝石》的开门和淡出过渡有多长?
门以四张各五帧的画面打开(335 毫秒),玩家被强制走一步 16 帧进门,门在 20 帧内关上,屏幕分九个档位淡出,最后一次混合在淡出的第 17 帧,淡出在第 22 帧结束:在下一张地图能开始载入之前,至少 79 帧,1.32 秒。进洞时,屏幕淡出到白色而不是黑色;出洞时,则从白色淡入。152527
像素艺术游戏应该在 ProMotion iPhone 上跑 120Hz 吗?
对整像素运动来说,不需要。一个每个 60 赫兹拍移动一个像素的世界,在 120 赫兹下只会让每个像素显示两次刷新,在 80 下则保持时间不均匀。Apple 关于 ProMotion 的文章说,游戏享有 “special priority to 30Hz and 60Hz”(对 30Hz 和 60Hz 的特别优先级),iPhone app 必须设置 CADisableMinimumFrameDurationOnPhone 才能超过 60,而 RealityKit 通常以 60 渲染。以固定 60 赫兹的拍来模拟,显示屏是多少就是多少。67168
为什么我的 sprite 走路时脚会打滑?
通常是因为行走画面跑在一只时钟上、运动跑在另一只上,而两者的长度对不上。Kiradex 当前的代码一边以每秒 4 格行走,一边以每秒 8 张播放六张画面的循环,所以正如代码的模型所示,一个循环覆盖 3 格而不是 2 格。掌机把两者都按帧计数,让每一步的画面持续得和这一步一样长;按行走距离选择画面,也就是我为 Kiradex 提议的做法,不需要第二只时钟就能得到同样的一致。无论哪种,节奏都不会偏离运动;至于脚看起来是否踩稳,还取决于画面本身。619
手机像素游戏该用虚拟方向键吗?
依我对这些资料的解读,不该作为默认。星露谷移动版的默认是点按移动,另有一个隐形摇杆作为针对精确任务的其他方案之一;Apple 的 HIG 建议直接点按对象,并使用一个出现在 “wherever the player lands their thumb instead of a static thumbstick position.”(玩家拇指落下的任何位置,而不是固定位置)的摇杆。4044
本站相关内容:在 iPhone 上造一个像素艺术世界 是本系列的第一篇指南,讲了《绿宝石》的迈步与站立行走、这个世界所依托的 RealityKit 配方,以及卡牌查看器的不透明度教训;像素艺术人物:iPhone 上的角色与捏人器 是第二篇,讲的是本文把计时从时钟改为距离的那套六张画面的行走;像素艺术建筑:iPhone 上的房屋、大厅与室内 是第三篇,讲的是第 2 节测量了时序的门、传送点和楼层;面向开发者的 iPhone Duo 和 为 iPhone Duo 准备你的 app 讲的是简报中的相机要避开的两块显示屏和折叠;RealityKit 的空间心智模型 解释了 SceneEvents.Update 背后的实体与系统模型。
参考来源
-
作者测量,2026 年 10 月 4 日:
measure_gen3_motion.py(位于作者为本文准备的证据文件夹中),对 pret 的pokeemerald提交731ad5b运行;它解析src/event_object_movement.c中的步进函数表和InitMoveInPlace时长、src/data/object_events/object_event_anims.h中的动画表、src/sprite.c中的延时计数器规则以及src/field_door.c中的门帧,并按 59.7275 赫兹换算帧数。输出保存为旁边的measure_gen3_motion.out.txt(速度为每秒 3.73、7.47、9.95、14.93 和 29.86 格;原地踏步为 32、16、8 和 4 帧;sAnim_GoSouth为 32 帧;门画面保持 5 次更新,83.7 ms,四张共 335 ms)。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
pret,
pokeemerald/src/event_object_movement.c(sStep1Funcs至sStep8Funcs,以及注释 “Over the course of the step animation, these sum to 16 pixels (one full metatile)”;sStepTimes和用sTimer逐帧索引步进表的NpcTakeStep;在一步开始时设定该步态动画与交替的SetStepAnimHandleAlternation;CameraObject_UpdateMove),提交731ad5b,2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/event_object_movement.c ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
pret,
pokeemerald/src/overworld.c(OverworldBasic的顺序RunTasks(); AnimateSprites(); CameraUpdate(); UpdateCameraPanning();,以及同一函数中稍后的UpdatePaletteFade();调用TransferPlttBuffer的VBlankCB_Field;先于InitCameraUpdateCallback(gPlayerAvatar.spriteId)的InitPlayerAvatar)和src/sprite.c(AnimateSprites按槽位顺序运行回调),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/overworld.c 和 https://github.com/pret/pokeemerald/blob/master/src/sprite.c 。“同一帧” 的结论是作者对代码的解读,并未实际运行。 ↩↩↩↩↩↩↩ -
作者测量,2026 年 10 月 4 日:
measure_gen12_motion.py(位于作者为本文准备的证据文件夹中),对 pret 的pokeredd2704a6(home/overworld.asm、home/fade.asm)和pokecrystal5beda23(engine/overworld/events.asm、engine/overworld/map_objects.asm、data/maps/setup_scripts.asm、engine/tilesets/timeofday_pals.asm)运行。输出保存为measure_gen12_motion.out.txt(《红》:16 帧 16 px,自行车 8 帧,传送淡出 32 帧;《水晶》:行走为 8 次 2 px 更新,自行车 4 次 4 px,慢步 16 次 1 px、每秒 1.87 格,门淡出单程 8 帧)。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
作者测量,2026 年 10 月 5 日:
measure_gen3_fade.py(位于作者为本文准备的证据文件夹中),是从 pret 的pokeemerald/src/palette.c(731ad5b)移植到 Python 的BeginNormalPaletteFade、带待传输标志的UpdatePaletteFade、UpdateNormalPaletteFade和IsSoftwarePaletteFadeFinishing,按其调用者的时间表运行:门的任务在RunTasks内启动淡出(Task_DoDoorWarp,src/field_screen_effect.c);BeginNormalPaletteFade更新一次,把缓冲区复制到调色板内存并清除标志;OverworldBasic(src/overworld.c)在同一帧再更新一次;之后每帧更新一次,由 VBlank 传输清除标志。帧从任务所在的帧记为 0。假设之前没有正在进行的淡出;若下雨、下雪、起雾、阴影或干旱处于活动状态,src/field_weather.c中的FadeScreen会复制被天气染色的缓冲区,再调用同一个BeginNormalPaletteFade(天气自己处理淡出的函数是DoNothing),所以淡出时间表依然成立,而淡入经由天气代码,未作模拟;传送后的淡入从地图载入回调开始,其第一帧未追踪,因此给出两种情况。输出保存为measure_gen3_fade.out.txt(档位从 0 到 16,步长 2;第一次可见的混合在第 1 帧;最后一次混合,即 sprite 调色板到达 16,在第 16 帧,从第 0 帧算起 285 ms;第 21 帧变为非活动,368 ms;延迟为 8 的FadeInFromWhite从第 0 帧算起在第 85 或 86 帧变为非活动,即经过 86 或 87 帧之后,1,440 或 1,457 ms;进门:开门 20、走步 16、关门 20 帧,最后一次淡出混合至少在 73 帧之后,1.22 s,而由于Task_WarpAndLoadMap要等淡出变为非活动,WarpIntoMap最早在第 79 帧,1.32 s)。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
作者建模,2026 年 10 月 5 日:
measure_kiradex_motion.py(位于作者为本文准备的证据文件夹中),从Kiradex/World/PlazaRig.swift读取tilesPerSecond(4)、framesPerSecond(8)、runPace(1.6)、跑步阈值(6 步)和相机的min(1, dt * 6),从Kiradex/World/TileMap.swift读取centre,从scripts/forge/rig.py读取六张画面的walk和run循环,并逐帧模拟advance、place、animate和follow:每个Float运算都以 32 位(numpyfloat32)按代码顺序执行,采用 Swift 的远离零取整,帧间隔恰为 1/60 和 1/120 s,模拟 5 格行走、保持行走节奏走 12 格,以及 12 格跑步,之后各站立 3 s;略去地图夹取、折叠和脚底偏移。这是代码的模型,不是从设备上录制的。输出保存为measure_kiradex_motion.out.txt。60 Hz 行走:每格 15 帧,每格 14 帧 1 px、一帧 2 px;相机差距在前五格结束时为 5、6、7、8 和 9 px,保持行走节奏时从第九格起为 13;5 格行走中第一帧之后的 73 个移动帧里,屏幕位置有 8 帧变化(模型中的行走共有 74 个移动帧,最后一格的最后一帧算作站立);静止偏移 4 px。120 Hz:每格 30 帧,16 帧 1 px、14 帧 0;差距 9;静止 9。60 Hz 跑步:每格 10 帧,每秒 6.00 格,每格 4 帧 1 px、6 帧 2 px;从第二格起差距 13;静止 4。120 Hz 跑步:每格 19 帧,每秒 6.32 格;差距 9;静止 9。以 60 Hz 下模拟位置量出的相邻两次循环开始之间的移动:行走 3.00 格,跑步 4.50 格(按名义每秒 6.4 格为 4.80);120 Hz 下,行走 3.00 和 2.94,跑步 4.75。当前代码中的对角步:60 Hz 下行走 22 帧、跑步 14 帧,120 Hz 下 43 和 27。与把时钟、位置和相机都改用双精度的同一模型相比,所有位置和相机值都不变,60 Hz 下有 9 张、120 Hz 下有 14 张行走画面不同,全都出现在时钟乘以 8 为整数的帧上(12 格行走中这样的帧 60 Hz 下有 11 个,120 Hz 下有 23 个)。提议:固定 60 Hz 的拍在 60 Hz 下让每个像素保持 1 次刷新,在 120 Hz 下 61 个位置中有 59 个保持 2 次刷新,在 80 Hz 下保持 1 次或 2 次刷新,分别为 42 个和 19 个位置。累加器:从 120 到 10 Hz 的十二种 iPhone 速率各跑十秒回调,积压上限为 8 拍时运行 600 拍、不丢弃任何东西,而每次回调上限 4 拍时,12 Hz 下为 480,10 Hz 下为 400;60 Hz 下一次 1 s 的卡顿之后,8 拍规则先运行 8 拍,之后每次回调 1 拍,丢弃 866.7 ms,而保留积压的 4 拍上限会连续 19 次回调各运行 4 拍。相机算术:fit对 3 倍的 393×852 pt 视野得出每纹素 7 个屏幕像素、168.43×365.14 世界像素的视野、半宽 84.21,向内取整的边界为 85 和地图宽度减 85;折叠从 0 变为 −37、分 9 档,会经过 0、−5、−9、−14、−19、−23、−28、−32、−37。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Apple Developer Documentation,“Optimizing iPhone and iPad apps to support ProMotion displays”(iPhone 的 10 到 120 Hz 范围及其十二档速率;设备列表;
CADisableMinimumFrameDurationOnPhone;30 和 60 Hz 的游戏优先级;“Prepare your app to operate at any refresh rate”;targetTimestamp),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays ↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Apple Developer Documentation,“Improving the Performance of a RealityKit App”(“RealityKit typically limits the refresh rate” 到每秒 60 帧),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/realitykit/improving-the-performance-of-a-realitykit-app ↩↩↩↩↩↩↩
-
pret,
pokeemerald/src/data/object_events/object_event_anims.h(sAnim_GoSouth、sAnim_GoFastSouth、sAnim_GoFasterSouth、sAnim_GoFastestSouth、sAnim_RunSouth)和src/sprite.c(载入帧时长减一的animDelayCounter,由ContinueAnim倒数,归零时取下一帧),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/data/object_events/object_event_anims.h 和 https://github.com/pret/pokeemerald/blob/master/src/sprite.c ↩↩↩↩↩↩↩↩↩↩↩ -
pret,
pokeemerald/src/field_player_avatar.c(PlayerWalkNormal、PlayerRun、带注释 “same speed as running” 的PlayerWalkFast、CheckMovementInputNotOnBike与TURN_DIRECTION、PlayerTurnInPlace、撞墙),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/field_player_avatar.c ↩↩↩↩↩ -
pret,
pokeemerald/src/fieldmap.c(GetBorderBlockAt:布局的 2×2 边界元图块,标记为MAPGRID_IMPASSABLE),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/fieldmap.c ↩↩↩ -
pret,
pokeemerald/src/field_camera.c(CameraUpdate;CameraPanningCB_PanAhead,它把sVerticalCameraPan从静止值 32 朝 72 或负 8 每次移动 2,受gUnusedBikeCameraAheadPanback保护,注释为 “this code is never reached”),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/field_camera.c ↩↩↩↩↩↩↩↩ -
Blake Crosley,“Pixel-Art Structures: Houses, Halls and Interiors on iPhone”,blakecrosley.com,2026 年 10 月 3 日(《绿宝石》的门画面保持五次更新,约 84 毫秒;《红》的
PlayerStepOutFromDoor;《绿宝石》的电梯震动;列为缺口的 Kiradex 70 毫秒的门和 220 毫秒的传送),https://blakecrosley.com/blog/pixel-art-structures-on-iphone ↩↩↩↩↩↩ -
作者为建筑篇所做的研究笔记,Kiradex 仓库(私有),
docs/research/structures/01-structures-in-the-canon.md,2026 年 10 月 3 日,其中把《绿宝石》的门帧写作 “4 ticks each (16 frames, about 0.27 s at 59.7 Hz)”;已由本文对field_door.c中AnimateDoorFrame的解读更正。 ↩↩ -
Apple Developer Documentation,“preferredFrameRateRange”(
CADisplayLink;iOS 15.0、iPadOS 15.0、Mac Catalyst 15.0、visionOS 1.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/quartzcore/cadisplaylink/preferredframeraterange ↩↩↩ -
Apple Developer Documentation,“CADisableMinimumFrameDurationOnPhone”(Information Property List 键;iOS 15.0、iPadOS 15.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/bundleresources/information-property-list/cadisableminimumframedurationonphone ↩↩↩
-
作者对 Kiradex 仓库(私有)提交
b1b78b1的阅读,2026 年 10 月 4 日,只读:Kiradex/World/PlazaRig.swift(tilesPerSecond、framesPerSecond、runPace,walk(to:)及其steps.count >= 6跑步规则,进度步长为dt * Self.tilesPerSecond * (walker.running ? Self.runPace : 1) / length的advance,animate,place,用整数pixelScale去除视野屏幕像素的fit,以displayScale / pixelScale换算折叠点数、以min(1, dt * 6)缓动后再取整、且其夹取只在 Duo 半开时让相机越过地图边缘以beyondMargin即 12 格进入树林的follow,不论折叠与否都铺设树林的build,在有水的地图上让地面的水每秒换 4 次画面的ripple,openDoor及其注释 “at about Emerald’s four ticks a frame”),Kiradex/World/PlazaStage.swift(点按处理、220 毫秒的传送延迟、对SceneEvents.Update的订阅、以.id替换楼层),Kiradex/World/TileMap.swift(八方向path,取出当前代价最低的开放图块,每步加 1 或 √2,不使用到目标的距离估计,且对角步只有在两侧格子都可行走时才允许),Kiradex/World/WorldMap.swift(传送点的门图集,“closed, half open, open”),用SpriteSheet.bundled(name, columns: 3, rows: 1)载入该图集、先显示第 1 列再显示第 2 列的openDoor,scripts/forge/kit.py(door_sheet(),“The three frames side by side, 48 × 32”),scripts/forge/town.py和scripts/forge/buildings.py(镇上每个门传送点都位于建筑最底一排的门格上,每扇门左、右、上方的建筑格都不可通行;已在出货的town.json中对全部七扇镇上的门核对),Kiradex/Views/Card/CardViewer.swift(蒙版的.easeOut(duration: 0.25))以及scripts/forge/rig.py(循环)。UIImpactFeedbackGenerator、sensoryFeedback、CHHaptic、CADisplayLink、preferredFrameRateRange和CADisableMinimumFrameDurationOnPhone的缺失,是 2026 年 10 月 4 日对Kiradex/和project.yml搜索的结果,一个也没找到;同一搜索在Kiradex/中也找不到MTKView、MTLRenderCommandEncoder或TouchController,所以世界没有自己的渲染通道。一步之中点按造成的回拽读自walk(to:),并非录制。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
pret,
pokered(提交d2704a6,2026 年 9 月 22 日)、pokecrystal(5beda23,2026 年 9 月 29 日)和pokeemerald(731ad5b,2026 年 10 月 1 日)的浅克隆,即社区对已发售游戏的反编译,https://github.com/pret ↩ -
Martin Korth,GBATEK,“LCD Dimensions and Timings”(280,896 个周期,每帧 16.743 ms,“ca. 59.737 Hz”),2026 年 10 月 4 日访问,https://problemkaputt.de/gbatek-lcd-dimensions-and-timings.htm ;Pan Docs,“Rendering”(“One frame: 70224 dots @ 59.7 fps”),2026 年 10 月 4 日访问,https://gbdev.io/pandocs/Rendering.html 。59.7275 这个数字是作者的算术:2^24 / 280,896,以及 4,194,304 / 70,224。 ↩↩↩↩↩
-
pret,
pokeemerald/src/bike.c(sMachBikeSpeedCallbacks、上限为 2 的bikeFrameCounter、调用PlayerRideWaterCurrent的AcroBikeTransition_Moving,以及唯一的赋值gUnusedBikeCameraAheadPanback = FALSE),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/bike.c ↩↩↩ -
插图由作者的脚本
make_figures.py(配合svgkit.py)于 2026 年 10 月 4 日绘制,只使用磁盘上的数据:经典作品的速度来自measure_gen3_motion.out.txt和measure_gen12_motion.out.txt;Kiradex 的行走和相机来自measure_kiradex_motion.py自身的simulate()(5 格行走的第一秒;60 和 120 Hz 下 5 格行走以及 60 Hz 下 12 格跑步的相机),提议的拍循环也来自同一脚本;《绿宝石》的淡出来自measure_gen3_fade.py的run(),即按调用者时间表运行的移植,门图中的淡出和最早的地图载入也来自同一处;Kiradex 的门时序(70 ms、1,400 ms、220 ms)读自b1b78b1的PlazaRig.swift和PlazaStage.swift。未使用任何游戏美术。 ↩↩↩↩↩ -
Blake Crosley,“Pixel-Art Worlds on iPhone: What the 16-Bit Masters Knew”,blakecrosley.com,2026 年 10 月 3 日(《绿宝石》的行走 “step, stand, step, stand at eight ticks each”;《红》的 “stand, step, stand, step-flipped”;以 320×180 渲染再放大六倍的 Celeste;RealityKit 引擎;位置 “rounded to whole world units each frame, after easing”;卡牌查看器的不透明度教训),https://blakecrosley.com/blog/pixel-art-world-on-iphone ↩↩↩↩↩↩↩↩
-
Itay Keren,“Scroll Back: The Theory and Practice of Cameras in Side-Scrollers”,Game Developer,2015 年 5 月 11 日,是他在 GDC 2015 独立游戏峰会上演讲的修订版(position-locking、edge-snapping、camera-window、lerp-smoothing、target-focus、dual-forward-focus;各处引文),2026 年 10 月 4 日访问,https://www.gamedeveloper.com/design/scroll-back-the-theory-and-practice-of-cameras-in-side-scrollers ↩↩↩↩↩↩↩↩
-
pret,
pokeemerald/src/field_door.c(sDoorOpenAnimFrames{4, -1}, {4, 0}, {4, 0x100}, {4, 0x200};AnimateDoorFrame,在计数器为 0 时绘制,在计数器等于该帧时间时前进),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/field_door.c ↩↩↩↩ -
pret,
pokeemerald/src/field_screen_effect.c(Task_DoDoorWarp的各状态,第五个调用WarpFadeOutScreen并把任务交给Task_WarpAndLoadMap,后者在WarpIntoMap之前等待PaletteFadeActive(),即gPaletteFade.active,以及BGMusicStopped();Task_ExitDoor,调用GetMapPairFadeToType的WarpFadeOutScreen,调用GetMapPairFadeFromType的WarpFadeInScreen,以及使用FadeScreen(FADE_FROM_WHITE, 8)的FadeInFromWhite),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/field_screen_effect.c ↩↩↩↩↩↩↩↩↩↩ -
pret,
pokeemerald/src/palette.c(设定deltaY = 2、自行调用UpdatePaletteFade、执行CpuCopy32到调色板内存并设sPlttBufferTransferPending = FALSE的BeginNormalPaletteFade;在该标志置位时立即返回、并在每次更新后根据gPaletteFade_selectedPalettes置位标志的UpdatePaletteFade;清除标志的TransferPlttBuffer;UpdateNormalPaletteFade;IsSoftwarePaletteFadeFinishing),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/palette.c ↩↩ -
pret,
pokeemerald/src/fldeff_flash.c(sTransitionTypes,共 16 行,进出MAP_TYPE_UNDERGROUND,每行有进入标志、离开标志和一个过渡例程;返回某行进入标志的GetMapPairFadeToType和返回其离开标志的GetMapPairFadeFromType),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/fldeff_flash.c ↩↩↩ -
pret,
pokeemerald/src/field_specials.c(ShakeCamera,从VAR_0x8004到VAR_0x8007读取纵向平移、横向平移、震动次数和延迟,每次震动时把平移取反),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/field_specials.c ↩ -
作者计数,2026 年 10 月 4 日:
measure_gen3_shake.py(位于作者为本文准备的证据文件夹中)读取 pret 的pokeemerald(731ad5b)中data/**/*.inc全部的special ShakeCamera调用及其前面的四个setvar值。输出保存为measure_gen3_shake.out.txt(9 个文件中的 24 次调用,表中的八组参数及其次数)。 ↩↩↩↩↩↩ -
pret,
pokered/home/overworld.asm(OverworldLoop、OverworldLoopLessDelay、wWalkCounter、AdvancePlayerSprite、DoBikeSpeedup,以及 180 度转身的注释),提交d2704a6,2026 年 10 月 4 日访问,https://github.com/pret/pokered/blob/master/home/overworld.asm 。转身行为读自代码,未在模拟器中运行。 ↩↩↩↩↩↩ -
pret,
pokered/engine/overworld/movement.asm(UpdatePlayerSprite,动画内部计数器到 4 时推进画面),2026 年 10 月 4 日访问,https://github.com/pret/pokered/blob/master/engine/overworld/movement.asm ↩↩ -
pret,
pokered/home/fade.asm(GBFadeOutToBlack)和home/overworld.asm(PlayMapChangeSound,门图块$0b时为SFX_GO_INSIDE,否则为SFX_GO_OUTSIDE),2026 年 10 月 4 日访问,https://github.com/pret/pokered/blob/master/home/fade.asm ↩ -
pret,
pokecrystal/engine/overworld/events.asm(MaxOverworldDelay: db 2)和engine/overworld/map_objects.asm(StepVectors;StepFunction_Turn,其.init1、.step1、.init2和.step2依次贯穿,以及ObjectStep_AnonJumptable),提交5beda23,2026 年 10 月 4 日访问,https://github.com/pret/pokecrystal/blob/master/engine/overworld/events.asm 和 https://github.com/pret/pokecrystal/blob/master/engine/overworld/map_objects.asm 。转身的三次更新是作者对该例程逐条指令的重放,不是模拟器运行。 ↩↩↩↩↩ -
pret,
pokecrystal/data/maps/setup_scripts.asm(以FadeOutToWhite开头的MapSetupScript_Door,以FadeInFromWhite结尾的MapSetupScript_Warp)和engine/tilesets/timeofday_pals.asm,2026 年 10 月 4 日访问,https://github.com/pret/pokecrystal/blob/master/data/maps/setup_scripts.asm 和 https://github.com/pret/pokecrystal/blob/master/engine/tilesets/timeofday_pals.asm ↩ -
Blake Crosley,“Pixel-Art People: Characters and a Creator on iPhone”,blakecrosley.com,2026 年 10 月 3 日(六帧行走;广场中的 “the walk at eight frames a second”;“Since publishing” 一节中,TestFlight 第 34 版构建把 26 像素的人物换成 32×40 格子里 30 像素的人物,
b1b78b1的scripts/forge/rig.py将其设为CELL_W, CELL_H = 32, 40),https://blakecrosley.com/blog/pixel-art-characters-on-iphone ↩↩↩ -
Celeste 的开发者,GitHub 上的
NoelFB/Celeste仓库,Source/Player/Player.cs(“Camera (lerp by distance using delta-time)” 之下的相机更新和CameraTarget的 getter),2026 年 10 月 4 日访问,https://raw.githubusercontent.com/NoelFB/Celeste/master/Source/Player/Player.cs ↩↩↩↩ -
Celeste 的开发者,
NoelFB/Celeste仓库,README.md(类文件 “as a learning resource and for general interest” 发布;MIT 许可证只适用于这部分代码),2026 年 10 月 4 日访问,https://raw.githubusercontent.com/NoelFB/Celeste/master/README.md ↩ -
Stardew Valley Wiki,“Speed”(玩家基础速度:行走 2,跑步 5,骑马 6.6,喂胡萝卜后 7),2026 年 10 月 4 日访问,https://stardewvalleywiki.com/Speed ↩
-
作者为本文所做的研究笔记,2026 年 10 月 4 日整理,记录了搜索过但没有找到的内容:《星之海》《风来之国》《远星物语》相机方面的一手资料;Maddy Thorson 谈相机的演讲或文章;像素复刻版确切的触控方案;https://terraria.wiki.gg/wiki/Mobile_version 上《泰拉瑞亚》移动版的移动方式;以及返回 404 的
developer.apple.com/documentation/touchcontrols。 ↩↩↩ -
Stardew Valley Wiki,“Mobile Controls”(各操控方案;作为默认的 “Tap-to-move & Auto-Attack”;关于点按移动、跟随触点、隐形摇杆和默认操控局限的引文),2026 年 10 月 4 日访问,https://stardewvalleywiki.com/Mobile_Controls ↩↩↩↩↩↩↩
-
Jared Nelson,“’Stardew Valley’ is Getting A TON of New Control Options in the Next Update”,TouchArcade,2018 年 11 月 1 日,2026 年 10 月 4 日访问,https://toucharcade.com/2018/11/01/stardew-valley-mobile-controls-update/ ↩
-
App Store,SQUARE ENIX 的 “FINAL FANTASY”,版本历史(1.2.0 版,03/11/2025,以及关于点按移动的说明),2026 年 10 月 4 日访问,https://apps.apple.com/us/app/final-fantasy/id1492041278 ↩↩
-
Mikhail Madnani,TouchArcade,2024 年 1 月 30 日,关于为移动版带来手柄支持的《最终幻想 像素复刻版》更新,2026 年 10 月 4 日访问,https://toucharcade.com/2024/01/30/final-fantasy-pixel-remaster-mobile-controller-support-update-boosts-cheats-font-not-fixed-steam-deck-patch-notes/ ↩
-
Apple Human Interface Guidelines,“Game controls”(触控操控最佳实践;2025 年 6 月 9 日的更新记录条目),2026 年 10 月 4 日访问,https://developer.apple.com/design/human-interface-guidelines/game-controls ↩↩↩↩↩↩↩
-
Apple,WWDC25 讲座 209(Touch Controls 框架;“the vast majority of players won’t have a controller available”;“integrates directly with Metal”),文字稿 2026 年 10 月 4 日访问,https://developer.apple.com/videos/play/wwdc2025/209/ ↩↩↩
-
Apple Human Interface Guidelines,“Playing haptics”(最佳实践、自定义触感以及 iOS 的 impact 类别;各处引文),2026 年 10 月 4 日访问,https://developer.apple.com/design/human-interface-guidelines/playing-haptics ↩↩↩↩↩↩↩↩↩
-
Apple Developer Documentation,“CAFrameRateRange”(iOS 15.0、iPadOS 15.0、Mac Catalyst 15.0、visionOS 1.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/quartzcore/caframeraterange ↩
-
Apple Developer Documentation,“targetTimestamp”(
CADisplayLink;iOS 10.0、iPadOS 10.0、Mac Catalyst 13.1、visionOS 1.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/quartzcore/cadisplaylink/targettimestamp ↩ -
Apple,WWDC21 讲座 10147,“Optimize for variable refresh rate displays”(Mac 上的 Adaptive-Sync 显示屏和 iPad Pro 上的 ProMotion;各处引文),文字稿 2026 年 10 月 4 日访问,https://developer.apple.com/videos/play/wwdc2021/10147/ ↩↩↩
-
Apple Developer Documentation,“SceneEvents.Update”(iOS 13.0、iPadOS 13.0、Mac Catalyst 13.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/realitykit/sceneevents/update ↩
-
Apple Developer Documentation,“deltaTime”(
SceneEvents.Update;iOS 13.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/realitykit/sceneevents/update/deltatime ↩ -
Apple Developer Documentation,“RealityView”(iOS 18.0、iPadOS 18.0、Mac Catalyst 18.0、visionOS 1.0;通过
System或SceneEvents.Update编写逐帧代码),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/realitykit/realityview ↩ -
Apple Developer Documentation,“CHHapticPattern”(Core Haptics;iOS 13.0、iPadOS 13.0、Mac Catalyst 13.0、visionOS 1.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/corehaptics/chhapticpattern ;Core Haptics 框架页面,https://developer.apple.com/documentation/corehaptics ↩
-
Apple Developer Documentation,“CHHapticEvent” 和 “CHHapticEvent.EventType”(
hapticTransient、hapticContinuous;iOS 13.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/corehaptics/chhapticevent 和 https://developer.apple.com/documentation/corehaptics/chhapticevent/eventtype ↩ -
Apple Developer Documentation,“CHHapticEvent.ParameterID”(iOS 13.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/corehaptics/chhapticevent/parameterid ↩
-
Apple Developer Documentation,“CHHapticEngine”(iOS 13.0;
capabilitiesForHardware()),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/corehaptics/chhapticengine ↩↩ -
Apple Developer Documentation,“UIImpactFeedbackGenerator”(iOS 10.0、iPadOS 10.0、Mac Catalyst 13.1;“Initializing the feedback generator” 下的
init(style:view:)和 Deprecated 下的init(style:)),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/uikit/uiimpactfeedbackgenerator ↩↩ -
Apple Developer Documentation,“UIImpactFeedbackGenerator.FeedbackStyle”(iOS 10.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/uikit/uiimpactfeedbackgenerator/feedbackstyle ↩
-
Apple Developer Documentation,“impactOccurred(intensity:)”(iOS 13.0、iPadOS 13.0、Mac Catalyst 13.1),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/uikit/uiimpactfeedbackgenerator/impactoccurred(intensity:) ↩
-
Apple Developer Documentation,“prepare()”(
UIFeedbackGenerator;iOS 10.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/uikit/uifeedbackgenerator/prepare() ↩↩ -
Apple Developer Documentation,“sensoryFeedback(:trigger:)” 和 “SensoryFeedback”(SwiftUI;iOS 17.0、iPadOS 17.0、Mac Catalyst 17.0、visionOS 26.0;
impact(weight:intensity:)),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/swiftui/view/sensoryfeedback(:trigger:) 和 https://developer.apple.com/documentation/swiftui/sensoryfeedback ↩ -
Apple Developer Documentation,“Touch Controller”(iOS 26.0、iPadOS 26.0、Mac Catalyst 26.0、visionOS 26.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/touchcontroller ↩↩↩↩
-
Apple Developer Documentation,“TCDirectionPad”(iOS 26.0、iPadOS 26.0、Mac Catalyst 26.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/touchcontroller/tcdirectionpad ↩
-
Apple Developer Documentation,“GCVirtualController”(Game Controller;iOS 15.0、iPadOS 15.0、Mac Catalyst 15.0),2026 年 10 月 4 日访问,https://developer.apple.com/documentation/gamecontroller/gcvirtualcontroller ↩
-
pret,
pokeemerald/src/field_control_avatar.c(TryDoorWarp,在按住的方向与朝向一致时以玩家前方的格子调用,只有当该方向为北且该格是门传送点时才会传送),2026 年 10 月 4 日访问,https://github.com/pret/pokeemerald/blob/master/src/field_control_avatar.c ↩