像素藝術的動態:iPhone 上的走路、鏡頭與門
《寶可夢 紅》(Pokémon Red)、《寶可夢 水晶》(Pokémon Crystal)與《寶可夢 綠寶石》(Pokémon Emerald),Game Boy 與 Game Boy Advance 三個世代各取一款,走過一個 16 像素的格都是 16 個影格,約 59.73 赫茲,每秒 3.73 格,而且這一步沒有任何部分交給時鐘決定:《綠寶石》每個影格讓玩家恰好移動一個像素,每一步顯示一張跨步畫格與一張站立畫格,並在同一個影格以同樣的像素數捲動鏡頭。123 《紅》與《水晶》則是每隔一個影格移動兩個像素,同樣湊滿十六個影格。4 《綠寶石》的門以四張畫格打開,每張停留五個影格,玩家被強制往裡走一步,門關上,畫面再以九個階梯式的明度層級淡出:至少 79 個影格、1.32 秒之後,下一張地圖才可能開始載入。15 Kiradex 的世界依經過的時間走路,鏡頭採用緩動;依那段程式碼建立的模型(是模型,不是從手機上擷取的畫面)顯示,在 60 赫茲下 sprite 每走一格會跳動一次兩個像素,60 赫茲下每走一格鏡頭就再落後約一個像素(到第五格時落後 9 像素,跑步時 13 像素),停下後則停在離玩家 4 到 9 像素之處;走路畫格也有自己的一套時鐘,一個循環走 3.00 格,而《綠寶石》的一個循環只走兩格。6 Apple 會給遊戲 30 與 60 赫茲的特殊優先權,RealityKit 通常以 60 算繪,因此這份規格書的內容是:固定的 60 赫茲 tick 搭配整數像素的步進、依走過的距離挑選走路畫格(這是我的提案,並非掌機的做法)、鎖定的鏡頭、用我們自己的美術重現《綠寶石》的進門儀式、三個可關閉的觸覺事件,以及 60 赫茲而非 120。78 以下是從反編譯原始碼量出的經典做法、從 Apple 文件整理出的 iPhone 端資訊,以及這份規格書與它必須通過的檢查。
重點摘要
- 一步是一份契約,不是一個速度。 《綠寶石》的每一張步進表加總都是 16 像素:走路是每影格 1 像素、共 16 個影格(每格 268 毫秒),跑步與衝浪是每影格 2 像素、共 8 個影格,音速自行車(Mach Bike)的極速是每影格 4 像素、共 4 個影格,只有越野自行車(Acro Bike)的 2-3-3-2-3-3 不平均。《紅》與《水晶》同樣以 16 個影格走完一格,只是以每秒 30 次更新、每次跳 2 像素的方式進行。124
- 腿的動作與步伐的時間是對齊的。 《綠寶石》分別以影格計算畫格與步伐,並讓兩者的數字一致:走路是跨步、站立、跨步、站立,每張畫格 8 個影格,涵蓋兩個 16 影格的格;較快的步態把停留時間減半,而不是增加畫格,所以在任何速度下,每 16 像素都只落一次腳。從站立狀態轉身需要 8 個影格(134 毫秒),走路中轉身則不花時間,走進牆壁會播放 32 個影格的碰撞動作。19102
- 鏡頭就是玩家。 《綠寶石》的鏡頭複製玩家的位置,在同一個影格以相同的像素數捲動,既不落後也不超前,而且從不停在地圖邊緣,因為地圖外側是用邊界圖塊畫出來的。Game Freak 寫過一個會超前帶領自行車的鏡頭,出貨時卻把它關掉了。231112
- 一扇門是一場儀式。 四張各五個影格的畫格(335 毫秒)、16 個影格的強制步伐、20 個影格的關門,再加上一段淡出,它最後一次混色落在第 17 個影格,第 22 個影格才結束:載入地圖之前至少 79 個影格、1.32 秒,期間不接受任何輸入。這證實了本系列建築篇的數字,每張畫格約 84 毫秒,也修正了那篇文章背後的研究筆記,筆記裡寫的是 「4 ticks each (16 frames, about 0.27 s at 59.7 Hz).」(每張 4 tick,共 16 影格,在 59.7 Hz 下約 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 赫茲 tick、每步 1 像素並明訂積壓處理規則、依距離挑選走路畫格、以整數像素鎖定的鏡頭、完整的進門流程與階梯式淡出、把被拒絕的一步顯示出來、三個可關閉的觸覺事件、不要求 120 赫茲,而且每一項都附有一個可由動態日誌、錄影擷取或腳本驗證的檢查。
1. 《綠寶石》的走路:十六個像素,十六個影格
本系列的前兩篇量測了一個像素世界與其中的人物看起來是什麼樣子;第三篇量測了它的建築。這一篇量的是時間:每個影格幾個像素、每一步幾個影格、哪個影格顯示哪張畫格、轉身、開門與淡出各花多久,以及在這一切發生時鏡頭在做什麼。這些遊戲的讀法與前幾篇相同,依據 pret 對已發行的 Game Boy 與 Game Boy Advance 遊戲所做的反編譯,並使用前幾篇採用的 commit:pokered d2704a6、pokecrystal 5beda23、pokeemerald 731ad5b。18 計數工作由五支小腳本完成;每一支都在註釋中寫明名稱,並附上存檔的輸出。
先交代一個數字,因為其餘一切都以影格計算。Game Boy Advance 以一個 2^24 赫茲的時脈花 280,896 個週期畫出一個影格,也就是每秒 59.7275 個影格、每個影格 16.743 毫秒;GBATEK 將它取整為 「ca. 59.737 Hz」(約 59.737 Hz)。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 像素,即一整個 metatile。)2 sStep1Funcs 是十六次一像素的移動,sStep2Funcs 是八次兩像素,sStep4Funcs 是四次四像素,sStep8Funcs 是兩次八像素,而 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 | 滑動類的移動動作 |
每一列的來源:measure_gen3_motion.py 對 src/event_object_movement.c 的計數。1
最該記住的是走路的數字:每個影格一像素,每格十六個影格,每秒 3.73 格,每格 268 毫秒,PlayerWalkNormal 與所有走路的 NPC 都用它。1 按住 B 鍵跑步用的是 PlayerRun,衝浪用的是 PlayerWalkFast,原始碼在後者旁註明 「same speed as running」(與跑步同速);冰面滑行呼叫的也是同一個函式。這三者都是每影格兩像素、每格八個影格、每秒 7.47 格。110 另外還有一種慢走,UpdateWalkSlowAnim,只在計時器為偶數時前進一像素,每格 31 到 32 個影格,用於劇情演出。1
越野自行車是唯一像素數不平均的速度:六個影格依序 2、3、3、2、3、3,平均每影格 2.67 像素,每秒 9.95 格。1 它從 AcroBikeTransition_Moving 經由 PlayerRideWaterCurrent 進入,這個函式與水流用的是同一個。120 依我的解讀,這種不平均是一個無法整除十六的速度必須付出的代價:每影格三像素會超出這一格,所以這張表交替使用 2 與 3,好剛好落在 16。其他每一種速度都能整除一格。
音速自行車是以步為單位加速,而不是以影格。sMachBikeSpeedCallbacks 是 PlayerWalkNormal, PlayerWalkFast, PlayerWalkFaster,bikeFrameCounter 每走一步加一,上限為 2,所以從靜止起步的第一步花 16 個影格,第二步 8 個,之後每一步 4 個:每影格四像素,每秒 14.93 格。120 這台自行車從不處於兩種速度之間。每一步都是其中一張表,從頭跑到尾,速度只在格與格的交界處改變。
在《紅》《水晶》《綠寶石》中量到的每一種步態,每個 16 像素的格都是整數個影格;Kiradex 的數字是對其程式碼的模型推算,不是擷取。14621
每十六個像素落一次腳
走路動畫是跨步、站立、跨步、站立。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.」(踏、站、踏、站,每格八個 tick,正是大家記得的那種上下晃動。)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 一般與較快的原地踏步分別是 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 邊界 metatile,並標記為 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 為 true 時才會執行,而這個變數只被設為 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
門是二十個影格打開,不是十六
field_door.c 裡的門表看起來像是每張畫格花四個影格: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 tick,共 16 影格,在 59.7 Hz 下約 0.27 秒),app 自己的門程式碼裡也有一段註解同樣錯了,說門以 「at about Emerald’s four ticks a frame」(大約是 Emerald 每畫格四 tick)的速度打開,接著讓第一張畫格停留 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 那一列是從 app 的門程式碼讀出來的,不是在手機上計時。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,答案為 true 時各自以白色呼叫 FadeScreen,否則用黑色。25 兩者都在 sTransitionTypes 中查這一對,這張表有 16 列,涵蓋每一種地圖類型進出 MAP_TYPE_UNDERGROUND 的組合;前者回傳該列的進入旗標,後者回傳離開旗標,進入旗標只在進入洞窟的列上為 true,離開旗標只在離開洞窟的列上為 true。27 所以進入洞窟時,畫面淡出成白色,再從黑色淡入;離開洞窟時,則淡出成黑色,再從白色淡入。每一列也各自指定了一個洞窟轉場常式,我沒有追蹤。27 另有一個較慢的白色淡入,延遲為 8 的 FadeInFromWhite,在 86 或 87 個影格、1.44 到 1.46 秒之後變為非作用中;它是從地圖載入回呼啟動的,那個回呼的第一個影格我沒有追蹤,所以移植版兩種情況都列出。525
震動是寫在腳本裡的平移
《綠寶石》的畫面震動是一個腳本指令,不是物理效果。ShakeCamera 從四個腳本變數讀取垂直平移、水平平移、震動次數與每次震動之間的影格數,並在每次震動時把平移取反。28 遊戲的腳本裡共有 24 次呼叫,分布在 9 個檔案中,由一支腳本全數計算: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 |
來源:measure_gen3_shake.py 對 data/**/*.inc 全部檔案的計數。29
24 次中有 10 次是同一種震動:每個方向一像素,翻轉八次,間隔五個影格,670 毫秒。29 最大的是水平 3 像素;最長的是 100 個影格、1.67 秒。29 建築篇介紹過的電梯震動(每三個影格震一次,次數隨經過的樓層增加)是另一個常式,不在這次計數之內。13 依我的解讀,克制就是這裡的教訓:《綠寶石》的震動只有一兩個像素,只用在腳本判定重要的事件上,從不用於腳步或開門。
3. 《紅》與《水晶》:每秒更新 30 次的同一步
《紅》每隔一個影格移動兩個像素
《寶可夢 紅》沒有步進表。它的地圖主迴圈 OverworldLoop 呼叫 DelayFrame,然後落入 OverworldLoopLessDelay,後者又呼叫一次,因此世界每兩個影格更新一次。30 一步會把 wWalkCounter 設為 8,每一輪 AdvancePlayerSprite 把它減一,並以左移一位的步進向量捲動背景暫存器 hSCX 與 hSCY:2 像素。30 八輪、每輪 2 像素,就是 16 個影格走 16 像素,與《綠寶石》同樣是 268 毫秒、每秒 3.73 格,只是以每秒 30 次更新、每次跳 2 像素的方式走完。430 自行車則是每輪多前進一次:DoBikeSpeedup 再呼叫一次 AdvancePlayerSprite(在自行車道(Cycling Road)上按住上、左或右時除外),所以一步花 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.」(它不太可能被看見,因為 DelayFrame 在 OverworldLoop 開頭就被呼叫。)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 並穿過 .init2(寫入新的面向並再次設為 2),落入 .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(線性插值平滑)讓鏡頭朝目標緩動,他稱之為 「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 遊戲裡仍然確實存在。)他也舉出最簡單的方案正確的情況:position-locking 適用於 「a crafting adventure game like Terraria, with a small character relative to the screen with pretty small jumps, it works very well.」(像 Terraria 這樣的製作冒險遊戲,角色相對於螢幕很小、跳躍也很小,效果非常好。)23 一座俯視視角的城鎮,配上一個 30 像素的收藏者(本系列角色篇在 build 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-time 做線性插值)之下。3637 當倍數為 1、目標靜止不動時,不論影格率為何,它每秒都會縮小 99% 的差距;但 Celeste 的目標並不靜止,它每個影格都依玩家的位置與狀態重新計算,所以這個數字描述的是緩動本身,而不是鏡頭最後停在哪裡。36 目標是讓玩家位於遊戲畫面的中央(X - Celeste.GameWidth / 2),限制在房間的邊界之內,並在少數狀態下加上偏移:StRedDash 衝刺時朝衝刺方向超前 48 像素,山頂發射時向上 64 像素。36 本系列第一篇提到,Celeste 以 320 乘 180 算繪它的世界,再乘以六。22
依我的解讀,兩者並不衝突。Celeste 之所以平滑,是因為平台遊戲的跳躍會讓視角隨每次起跳上下拉扯;Keren 自己對 lerp-smoothing 的定義也是針對跳躍。格子上的行走者以固定速度直線移動,鎖定鏡頭不會產生需要平滑掉的頓挫,平滑過的鏡頭只會在原本就均勻的運動後面多拖出一段尾巴。第 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 這個 wiki 並沒有說明一個單位等於每 tick 幾個像素,所以這裡不提供《星露谷物語》每秒幾格的數字。
我也找過《星之海》(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」(會讓角色跟著觸控點走),而 wiki 也提醒,跟隨模式 「is very literal, moving directly towards the finger without routing around blocking objects.」(非常照字面執行,會直接朝手指移動,不會繞過擋路的物件。)隱形搖桿方案佔用 「the left half of the screen」(螢幕左半邊),中心就在您觸碰的位置。wiki 也坦白說明預設操作的極限:某些需要精確定位的工作 「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 上推出的初代《FINAL FANTASY》像素複刻版(Final Fantasy Pixel Remaster),於其 1.2.0 版加入了走路或跑步的預設選項,該 app 的 App Store 版本紀錄上的日期是 2025 年 3 月 11 日(這個系列各作以獨立 app 發行,我只查看了這一款):「In tap based movement mode the character controlled will always run as the default speed when moving.」(在點擊移動模式下,所操控的角色移動時一律以跑步作為預設速度。)42 手機版的控制器支援則是在 TouchArcade 於 2024 年 1 月 30 日報導的一次更新中加入的。43 除了這些說明之外,我找不到任何地方記載它確切的觸控移動方式,我所擷取的那個 wiki 頁面上也沒有關於 Terraria 手機版移動方式的描述。39
Apple 的《人機介面指南》從平台的角度說了同樣的事。關於觸控遊戲:「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 節規格書據以撰寫的規則,每一條都可以追溯到上文的一項量測。「tick」指一個 1/60 秒的模擬步,這是把 59.73 赫茲的影格換成 iPhone 能穩定維持的東西的方式;以每格 16 個 tick 計算,走路是每秒 3.75 格,而不是 3.73。119
| 要素 | 規則 | 來源 |
|---|---|---|
| 走路 | 在 16 像素的圖塊上每 tick 1 像素:每格 16 tick,每秒 3.75 格 | 《紅》《水晶》《綠寶石》都以 16 個影格走 16 像素,每秒 3.73 格 |
| 跑步 | 每 tick 2 像素,每格 8 tick;不採用無法整除 16 的速度 | 《綠寶石》的跑步與衝浪是 2,自行車是 4;只有越野自行車的 2-3-3 不平均 |
| 走路循環 | 在任何速度下每 16 像素落一次腳;我對 Kiradex 的提案是依走過的距離挑選畫格,六張畫格涵蓋 32 像素時,顯示第 floor(distance × 6 / 32) mod 6 張 |
《綠寶石》以影格分別計算畫格與步伐,長度彼此吻合:走路是涵蓋兩個 16 影格格子的 32 影格循環,跑步是涵蓋兩個 8 影格格子的 16 影格循環 |
| 一步 | 一旦開始就走完;在格上讀取輸入 | 《綠寶石》與《紅》都在一步完成時讀取方向鍵 |
| 轉身 | 從站立狀態 8 tick,約 133 ms(《綠寶石》的 8 個影格是 134);走路中不花時間 | 《綠寶石》WalkInPlaceFast 是 8;《水晶》的轉身常式 6 個影格;《紅》2 個 |
| 被拒絕的一步 | 要顯示,不要忽略:32 tick 的原地踏步加上碰撞 | 《綠寶石》WalkInPlaceSlow 是 32 |
| 鏡頭 | 鎖定在玩家身上:不落後、不超前,在同一個 tick 以相同像素數移動 | 《綠寶石》的鏡頭物件;Game Freak 寫過的唯一超前鏡頭被關掉了 |
| 地圖邊緣 | 畫出外側並維持鎖定,或加以限制(邊緣吸附);絕不緩動 | 《綠寶石》的邊界圖塊;Keren 的邊緣吸附;Celeste 的房間邊界 |
| 門 | 4 張畫格各停留 5 tick:每張 83 ms,打開共 333 ms(《綠寶石》是 84 與 335) | 《綠寶石》field_door.c |
| 淡出 | 9 個階梯式層級,每 2 tick 一層,最後一層在第 16 tick,共 18 tick(0.30 秒),淡出與淡入皆同;預設為黑色。這是改編,不是照抄 | 《綠寶石》的一般淡出,sprite 調色盤在同樣的偶數影格到達每一層,比背景調色盤晚一個影格,最後一次混色在第 16 影格,第 21 影格變為非作用中;進入洞窟時淡出成白色,離開時從白色淡入;《水晶》的 8 個影格是快的一端,《紅》的 32 個是慢的一端 |
| 進門儀式 | 74 tick,約 1.23 秒,不接受輸入:開門 20、走進 16、關門 20、淡出 18 | 《綠寶石》的進門:至少 73 個影格後變暗,地圖最早在第 79 影格載入(1.32 秒) |
| 震動 | 1 像素,翻轉 8 次,間隔 5 tick(40 tick,0.67 秒),只用於腳本事件 | 《綠寶石》24 次震動中最常見的一種 |
| 影格率 | 不論顯示器怎麼跑,都以 60 模擬;要求 60,不要求 120 | Apple 給遊戲 30 與 60 的優先權;RealityKit 通常以 60 算繪 |
| 觸覺回饋 | 確認事件,不確認步伐;設為可關閉 | Apple《人機介面指南》關於播放觸覺回饋的說明 |
| 輸入 | 我的建議:預設點擊行走;以浮動搖桿作為精確操作的選項;不放固定十字鍵 | 《星露谷物語》手機版的預設、HIG 的浮動搖桿;兩者都沒有把它定為規則 |
表格來源:《綠寶石》的走路、動畫與門的計數;1 它各自獨立的畫格與步伐計時器;92 它的鏡頭;123 它的淡出與震動;529 《紅》與《水晶》;433 Keren 與 Celeste;2336 Apple 關於影格配速、RealityKit 與觸覺回饋的指引;7846 輸入方面的參考資料。404244
其中兩條規則各需要補一句話。走路循環這一條,是在現代引擎中最容易做錯的,因為動畫系統數的是它自己的秒數,而走路數的是世界中的距離。掌機讓兩者保持一致的方法,是都以同樣的影格計算,並把長度挑選成彼此吻合;在影格時間會變動的手機上,我的提案是從走過的距離讀出畫格,結果相同,卻不需要第二個必須保持同步的時鐘。至於影格率這一條,並不是缺乏企圖心。一個每 tick 移動一整個像素的世界,在 tick 與 tick 之間沒有任何東西可以顯示,所以更快的顯示器只能重複同一張畫面,而第 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 的世界並不擁有自己的 display link。它在 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 節針對 app 現有程式碼所用的同一支模型腳本,只是改套用在提案上:一個以固定 60 赫茲 tick 推進、每 tick 一像素的世界,顯示器則顯示最新一個 tick 產生的結果。在 60 赫茲的顯示器上,每個世界像素恰好顯示一次更新。6 在 120 赫茲的顯示器上,一秒之內 61 個位置中有 59 個恰好停留兩次更新,兩個只停留一次。6 在 80 赫茲的顯示器上,停留次數在一次與兩次之間交替,42 個位置停留一次,19 個停留兩次。6 依我的解讀,120 赫茲的情況就是把 60 赫茲的情況畫兩遍,而 80 赫茲的情況是配速不均:一個像素有時停留 12.5 毫秒,有時停留 25 毫秒。
所以對 iPhone 上的格子世界而言,問題不在於 120 赫茲,而在於以 60 均勻配速:固定的 60 赫茲模擬 tick、每 tick 整數位移、以 tick 計算的動畫,以及顯示最新 tick 的算繪器。這也讓動態不受 RealityKit 所挑選的速率影響。WWDC21 的議程也就時間差提出了相關的論點。當一個慢影格讓 display link 跳過一次回呼時,該推進的時間差是 「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
輸入
觸控操作的 API 以第 4 節的術語來看如下。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 的儲存庫中閱讀了世界的走路、鏡頭與轉場程式碼,沒有做任何修改,並為它寫了一個逐影格的模型。這一小節的每一項內容,不是從那段程式碼讀出來的,就是由模型算出來的,並會註明是哪一種。模型以 32 位元、依程式碼的順序、採用 Swift 的取整方式,執行程式碼中的每一個 Float 運算,每個影格恰好是 1/60 或 1/120 秒,並省略了地圖邊緣、iPhone Duo 的折疊處與腳部偏移,這些都不會改變地圖中央的一段直線行走。它是程式碼的模型。它不是擷取,也沒有在任何手機上量測過。176
從程式碼讀出的行為:
- 輸入只有點擊行走,別無其他。 舞台把一次點擊轉換成一個世界座標點,然後點中某個收藏者、點中販售亭,或呼叫
walk(to:)。沒有拖曳,沒有十字鍵,app 的 target 中也沒有任何觸覺回饋呼叫:搜尋UIImpactFeedbackGenerator、sensoryFeedback與CHHaptic一無所獲。17 - 路徑是八方向的最短路徑搜尋,依目前為止走過的成本排序,不估計剩餘距離,因此它是 Dijkstra 搜尋而不是 A*;斜向的成本是 √2,而且絕不切過牆角。斜向的一步會側身面向,並在套用跑步的 1.6 倍之後把進度除以 √2,所以在模型中,60 赫茲下斜向一步走路要 22 個影格、跑步要 14 個,直線一步則是 15 與 10。176
- 速度就是時間。
tilesPerSecond是 4,也就是每秒 64 像素,當路徑有 6 步或以上時,走路會變成 1.6 倍速的跑步,所以 app 裡的走路是 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
- 轉場是導覽推入(navigation push),使用預設動畫;換樓層時以 identity 抽換舞台,沒有淡出;離開時呼叫
dismiss()。app 中唯一的遮罩是卡片檢視器的黑色覆蓋層,以.easeOut(duration: 0.25)製作動畫。17 - 沒有影格率請求。 app 與其專案檔中都沒有
CADisplayLink、preferredFrameRateRange或CADisableMinimumFrameDurationOnPhone;世界以SceneEvents.Update搭配事件的deltaTime推進。17
模型推算這段程式碼在影格時間穩定時,畫面上會呈現的樣子:
- 60 赫茲下每格一次兩像素的跳動。 在 60 赫茲下,走路每格要 15 個影格,每秒 4.00 格,每格的 16 像素由 14 個 1 像素的影格加上一個 2 像素的影格組成:每格一次 2 像素的跳躍,走五格就有五次。《綠寶石》則是每個影格恰好移動 1 像素。61
- 120 赫茲下不規則的 0 與 1。 在 120 赫茲下,走路每格要 30 個影格,每格的 16 像素由 16 個 1 像素的影格與 14 個不動的影格組成,不規則地交錯。6
- 跑步比它的常數慢。 在 60 赫茲下,跑步每格要 10 個影格,每秒 6.00 格,而 4 乘以 1.6 應該是 6.4,因為每格超出的部分都被捨棄了;每格的 16 像素由 4 個 1 像素的影格與 6 個 2 像素的影格組成。在 120 赫茲下,每格要 19 個影格,每秒 6.32 格,所以跑步的速度取決於影格率。6
- 一個拖在後面、永遠追不上的鏡頭。 在 60 赫茲下走路時,每走一格鏡頭就再落後約一個像素,從第一格結束時的 5 像素,到第五格結束時的 9 像素(第五格也是 app 會走的最長一段路),而在第一個影格之後,這段走路的 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 格。依我的解讀,一個循環涵蓋的地面比它的落腳次數多出一半,看起來就會像腳在滑,但模型並沒有量測腳踩在地面上的位置。畫格每到第 15 個影格也會落在刀鋒上,在 60 與 120 赫茲下皆然,此時時鐘乘以 8 恰好落在整數上,也就是兩張畫格的交界:像 app 那樣以 32 位元而非 64 位元保存時鐘,在 60 赫茲下走 12 格時,這 11 個影格中有 9 個顯示的畫格會改變,在 120 赫茲下則是 23 個中有 14 個。6
- 一步走到一半時再點一次,會讓行走者被拉回去。 這一項是從程式碼讀出的,不是模型推算也不是擷取:當行走者所在的格仍是這一步的起點時,
walk(to:)會把進度設為 0,所以下一個影格會把行走者畫在比原本位置最多落後 15 像素之處;其他收藏者從伺服器傳來的移動也會造成同樣的情況。17
每個顯示影格的像素數:經典做法是均勻的,今天的程式碼(模型推算,不是擷取)則不是,而固定的 tick 在 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. 以 tick 走路,而不是以時鐘。
修改。 在 rig 的更新中加入一個 60 赫茲的累加器。每次回呼把自己的 dt 加進去;如果累加器此時保存的時間超過 8 個 tick(133 毫秒),超出的部分就被捨棄,並以一行 dropped 連同毫秒數寫入動態日誌;接著執行累加器中的每一個完整 tick,每執行一個就減去一個 tick。累加器以整數單位(奈秒,或模型所用的 1/60,000,000 秒)計算時間,絕不以浮點數的秒計算:若使用 Double 或 Float 累加器,在某些更新率下,十秒測試的第 600 個 tick 會因一次取整誤差而差一點才到達邊界,結果只算出 599。這就是積壓處理規則:最多 8 個 tick 的延遲會在下一次回呼中補上,超過的部分則視為暫停,所以世界會從停下的地方繼續,而不是飛快地追趕。之所以選 8,是為了涵蓋 Apple 列出的 ProMotion iPhone 所有更新率,一直到 10 赫茲,而 10 赫茲每次回呼需要 6 個 tick。7 在模型中,十二種更新率各跑十秒的回呼,都恰好執行 600 個 tick,沒有任何捨棄;若改為每次回呼上限 4 個 tick,在 12 赫茲下只會執行 480 個,在 10 赫茲下 400 個。6 在 60 赫茲下發生一次一秒的卡頓後,這條規則會在下一次回呼執行 8 個 tick,之後每次 1 個,並記錄捨棄了 866.7 毫秒;同樣的卡頓若保留積壓、上限為 4,則會連續 19 次回呼各執行 4 個 tick,而這正是這條規則要防止的快轉。6 每個行走者保存一個步伐 tick 計數,而不是小數進度,畫出的偏移就是這個計數乘以每 tick 的像素數:都是精確的整數,因此行走者不再需要取整。走路是每 tick 1 像素、每格 16 tick;跑步是每 tick 2 像素、8 tick,並保留現行規則:路徑達 6 步以上就跑步。路徑搜尋允許的斜向步伐,保留程式碼原本就打算使用的 √2 及其順序,也就是先套用跑步的倍速,再除以 √2:走路的斜向一步花 23 tick(16√2 約為 22.6,無條件進位),跑步的斜向一步花 12 tick(8√2 約為 11.3,無條件進位),每一步在兩個軸上各移動 16 像素,移動發生在 floor(16 × t / 23) 或 floor(16 × t / 12) 改變的那些 tick 上。17 以走路來說,就是 23 個 tick 中有 16 個移動 1 像素,其餘 7 個不動;以跑步來說,12 個 tick 中有 8 個移動 1 像素、4 個移動 2 像素,這是規格書中唯一不平均的步態,正如越野自行車是《綠寶石》中唯一不平均的步態。1 沒有任何超出量需要捨棄。
理由。 第 5 節中走路與跑步的規則;以及模型推算的 60 赫茲下每格一次 2 像素跳動,和 120 赫茲下不規則的 0 與 1。61
檢查。 一個 -motionLog 啟動參數在每個 tick 記錄 tick 與玩家的 x, y,以及每一行 dropped。在現有的面向展示中(每個方向各走一格),一支腳本斷言每個走路 tick 恰好移動 1 像素、每格花 16 tick,且沒有任何 tick 移動 2 像素。一個斜向展示(每個方向各走一步斜向走路與一步斜向跑步)斷言分別為 23 與 12 tick、每一步在每個軸上 16 像素、每軸的位移在走路時為 0 或 1 像素、跑步時為 1 或 2 像素,而且斜向跑步比斜向走路快。單元測試以精確的整數單位,餵給累加器捏造的 dt 序列:從 120 到 10 赫茲的十二種 iPhone 更新率各跑十秒,都執行 600 個 tick 且沒有任何捨棄;在 60 赫茲下一個一秒的空檔,會在下一次回呼執行 8 個 tick,之後每次回呼 1 個,並記錄 866.7 毫秒被捨棄。在 iPhone 18 Pro Max 與 iPhone Duo 的內側顯示器上,同樣的動態日誌得到相同的每格 tick 數:顯示器的更新率不得改變走路。
2. 依距離挑選走路畫格。
修改。 這一項是我的提案,不是照抄:《綠寶石》以影格計算畫格的時間,讓它與步伐吻合,並沒有從距離讀出畫格。92 走路時,依以步數計算的走路進度挑選欄位,也就是自走路開始以來已完成的步數,加上目前這一步的 tick 數除以其長度:walkCycle[floor(steps × walkCycle.count / 2) % walkCycle.count],每兩步兩次落腳,跑步亦同。在直線的一步上,這等於走過的距離除以 32 像素,floor(distancePx × 6 / 32) mod 6。在斜向的一步上,它計算的是步數而不是那 √2 的長度,所以每一步開始時仍會落一次腳,只是步幅較長,這是我針對為直線步幅所畫的畫格所做的選擇。走路結束時顯示站立畫格,與《綠寶石》相同。閒置、眨眼與表情的時鐘維持原樣。以 tick 計時的循環也可以與步伐吻合,就像《綠寶石》那樣,但六張畫格涵蓋 32 個 tick 會需要 5 與 6 tick 不平均的停留時間;計算進度則不需要第二張表,而且對 app 日後加入的任何步態都成立。
理由。 在任何速度下每 16 像素落一次腳,而且節奏不可能與移動錯開;在模型中,現在的循環走路時涵蓋 3.00 格,跑步時 4.50 格。16 這也解決了角色篇的 「eight frames a second」(每秒八張畫格):在每 tick 1 像素下,六張畫格涵蓋 32 像素,每 5.33 像素換一張,也就是每秒 11.25 張畫格,而不是 8。35
檢查。 從動態日誌加上顯示的欄位判斷:兩張接觸畫格(工坊循環中的 walk_a 與 walk_d)在每 32 像素中的 0 與 16 像素處出現,誤差正負一,在 60 與 120 赫茲下皆然;在斜向展示中,它們出現在每一步的第一個 tick。
3. 走完這一步;保留點擊;加入浮動搖桿。
修改。 一步走到一半時的點擊,從正在走向的那一格規劃路徑,而不是從起點,並先把目前這一步走完;步伐計數絕不重設。其他收藏者從伺服器傳來的移動也套用同樣的規則。從站立狀態開始,若一次點擊的第一步會改變面向,就先轉身 8 tick 再移動。點擊玩家旁邊一個被擋住的格,或點擊前方那一格,會轉身面向它,並播放 32 tick 的碰撞動作,搭配第 6 項的拒絕觸覺回饋。手指按住不放時跟隨手指,如同《星露谷物語》的預設操作,但每當手指越過一格,就用現有的路徑搜尋重新規劃,因為《星露谷物語》的跟隨不會繞過障礙物,而我們的可以。一個四方向的浮動搖桿,預設關閉,在拇指落下處出現,建立在覆蓋於世界之上的 SwiftUI 拖曳手勢上;畫出的任何東西至少 44 乘 44 點。只有在某個測試版本顯示 Touch Controller 的搖桿(iOS 26)能畫在 RealityView 之上時,才用它取代拖曳手勢:Apple 把這個框架描述為給 「Metal-based games」(以 Metal 為基礎的遊戲)使用,它在 WWDC25 的議程說它 「integrates directly with Metal」(直接與 Metal 整合),而 Kiradex 的世界由 RealityView 繪製,沒有自己的算繪階段(render pass)。4562
理由。 第 5 節中關於一步、轉身、被拒絕的一步與輸入的規則。14044624517
檢查。 一個 UI 測試點擊東邊 6 格的位置,120 毫秒後再點擊北邊 6 格的位置;動態日誌顯示玩家的 x 沒有任何一個 tick 減少,且第一步的那一格在轉向前就已走完。一個 UI 測試點擊房子旁邊的牆;日誌顯示一次面向改變、一次 32 tick 的碰撞,且位置沒有改變。搖桿開啟時,從移動的第一個 tick 起向右拖曳並按住 60 個 tick,會在第 48 tick 前走完三格,在搖桿仍被按住時開始第四格,並在放開之後於第 64 tick 走完:日誌顯示玩家位於東邊 4 格、落在完整的一格上,放開時正在進行的那一步已走完,格與格之間沒有停頓。
4. 鎖定鏡頭。
修改。 把緩動換成一台與玩家在同一個 tick 設定、只使用整數世界像素的鏡頭:camera = clamp(player + foldOffset, low, high)。每一項都保持為整數,因為現在只有最後的取整讓鏡頭變成整數:邊界與折疊偏移都是小數。玩家的位置依第 1 項已是整數。折疊偏移由 follow 以點數乘以 displayScale / pixelScale 計算,在折疊狀態改變時取整一次,成為整數世界像素。限制的邊界向內取整,下界進位、上界捨去,因為畫面以世界像素計算的半寬不一定是整數:fit 把畫面的螢幕像素除以每個 texel 對應的整數螢幕像素,所以一個作為示例的 393 乘 852 點、3x 的畫面會得到 7,畫面寬 168.43 世界像素、半寬 84.21,鏡頭的邊界就變成 85 與地圖寬度減 85,因此沒有任何影格會顯示到地圖邊緣之外。176 當地圖比畫面小時,邊界會互換,現在的限制方式也允許這樣,而同樣的向內取整會讓整張地圖都留在畫面內。保留對地圖的限制;第 5 節的規則允許限制或畫出外側,而 Kiradex 的鏡頭只有在 Duo 半開時,才會越過地圖邊緣進入樹林,距離是整數格。17 在折疊狀態改變之前,把折疊偏移視為固定;當它從 a 變為 b 時,讓它依 a + round((b − a) × i / 8) 逐步變化,i 從 0 到 8,每 2 tick 一層,也就是淡出的節奏,這樣沿途的每個位置都是整數像素;例如從 0 變為負 37,會依序經過 0、−5、−9、−14、−19、−23、−28、−32 與 −37。6 保留樹林的視差,由鎖定的鏡頭計算,並像現在一樣取整。不要朝點擊的目的地做前瞻:Game Freak 做過一個並把它關掉,而點擊行走的目的地本來就在畫面上。12
理由。 第 5 節的鏡頭規則,以及模型推算的落後量(在一段走路中逐漸增加,到第五格時為 9 像素,跑步時為 13)、它造成的玩家在螢幕上位置的改變,以及它 4 與 9 像素的靜止偏移。6
檢查。 從動態日誌來看,在開闊廣場上走路的每一個 tick,鏡頭減去玩家都是常數(零,或折疊偏移),玩家停下後也相同,而且每個鏡頭值都是整數。一個單元測試以 393 乘 852 點、3x 的畫面呼叫 fit,讓玩家走到地圖的兩側邊緣,並斷言鏡頭位置都是整數,停在 85 與地圖寬度減 85;同一個測試接著改變折疊狀態,並斷言鏡頭經過 9 個整數像素位置、彼此相隔 2 tick 到達新的偏移,且中間沒有任何一個 tick 超出邊界。若用錄影擷取驗證,走路畫格不適合當標記,因為腳的形狀會隨畫格改變;除錯版本在玩家實體的位置畫一個一 texel 的標記,使用其他地方都沒用到的顏色,再由一支腳本在開闊廣場上走 6 格的每一個影格中找出它:每個影格都在同一個螢幕像素上。靠近地圖邊緣時,移動的是標記而不是鏡頭,而且只以整數像素移動。
5. 門、那一步與淡出,用 Kiradex 自己的美術。
修改。 進門,也就是一個帶有門圖表的傳送點。現在門是在行走者已經踏上門格之後才打開:這一步的 onStep 在那一格找到傳送點,就在那裡呼叫 openDoor。17 《綠寶石》從不讓玩家站在一扇關著的門上:TryDoorWarp 只在玩家位於下方那一格、朝北推向一扇門時才觸發,而 Task_DoDoorWarp 打開上方一格的門,然後才強制玩家踏上去。6525 規格書把觸發點往回移一格,以求一致:
- 當路徑的下一步是踏上一個門傳送點時,走路停在它前面那一格,進門流程從那裡、於第 0 tick 開始。凍結輸入並播放門的音效,一個 Kiradex 的音效。
- 以四個各 5 tick 的時段開門,對應《綠寶石》的四張畫格、各五個影格(以每秒 60 tick 計算每個時段 83 毫秒,《綠寶石》的五個影格是 84;共 20 tick),取代現在立即顯示半開畫格、70 毫秒時打開的做法:關、半開、開、開。Kiradex 的門圖表有三張畫格,關閉、半開與打開(工坊的
door_sheet()畫三張,app 以SpriteSheet.bundled(name, columns: 3, rows: 1)載入),而《綠寶石》的門是一張關閉加三張,所以打開的畫格會一直停留到第四個時段。若工坊另外畫一張介於半開與打開之間的第四張畫格,就能填入那個時段;這是可選的美術,不是必要條件。修正那段說 「four ticks」(四 tick)的註解。2417 - 讓玩家從那一格強制走一步到門格上,16 tick。城鎮的門位於建築的最下面一列,左右與上方都是建築的格,而路徑絕不切過牆角,所以這一步一定是從下方那一格往上,與《綠寶石》相同。17
- 隱藏行走者並關門,時段倒過來(開、開、半開、關),20 tick。
- 在世界上淡入一層黑色遮罩,共 9 個階梯式的不透明度層級(0、2/16,依此類推到 16/16),第
i層在淡出的第2i個 tick,所以最後一層落在第 16 tick,並維持到第 17 tick:18 tick。這是刻意選擇的改編,不是《綠寶石》的時序。《綠寶石》的 sprite 調色盤在與這層遮罩相同的偶數影格到達每一層,背景調色盤早一個影格,而且在第 16 影格做完最後一次混色後,還會執行五次收尾更新,到第 21 影格才變為非作用中;單一一層遮罩沒有第二層會落後,也沒有東西需要收尾。5 絕不要對承載 RealityView 本身的那個 view 做不透明度動畫;卡片檢視器在第一篇就教過這一課。22 - 停用動畫推入目的地,再以同樣的 9 個層級把遮罩淡出。
- 在室內的地墊上抵達,面朝上。離開時則反過來:在門格上抵達且門是開的,強制往下走一步 16 tick,門關上,然後交還輸入。
換樓層目前是抽換舞台、沒有淡出,改為套用同樣的遮罩,不需要門也不需要強制步伐。離開房間目前是呼叫 dismiss(),改為套用遮罩並強制走一步出去。
理由。 第 5 節中關於門、淡出與儀式的規則;現在場所在那一步之後 220 毫秒就切換,門以相隔 70 毫秒的兩張畫格顯示,畫面還會往側邊滑動。1517
檢查。 動態日誌從走路停在門下方那一格的 tick 開始計數。它顯示玩家仍在那一格上,門的時段在第 0、5、10 與 15 tick(關、半開、開、開);強制步伐在第 20 到 35 tick,每 tick 1 像素,於第 35 tick 到達門格,絕不提早;關門的時段在第 36、41、46 與 51 tick;遮罩的第一層在第 56 tick;推入不早於第 74 tick。一段沒有經過這個流程就停在門格上的走路,不通過這項檢查。動態日誌也在每一個 tick 記錄遮罩的不透明度:9 個不同的值,各維持 2 tick,出去時在第 56 到 73 tick,進來時同樣是這 9 個。螢幕錄影則在畫面中靜止不動的部分加以確認:不能用整個畫面,因為在有水的地圖上,地面的水每秒換 4 次畫格,其他收藏者也可能在走動,所以一支腳本取樣一塊事先選好的建築牆面,錄影中那塊區域沒有動畫圖塊,也沒有行走者經過,並計算出去時 9 個、進來時 9 個不同的層級,各維持 2 個影格,在 60 赫茲下誤差正負一。17 在一段傳送的螢幕錄影中,世界從不水平平移:沒有導覽的滑動。
6. 觸覺回饋:三個事件,加一個開關。
修改。 由世界持有一個 CHHapticEngine,在世界出現時啟動、消失時停止,並在設定中加入一個觸覺回饋開關,預設開啟,且遵循系統的觸覺回饋設定。圖樣以 AHAP 檔案保存在 bundle 中:
| 事件 | 圖樣 | 理由 |
|---|---|---|
| 被拒絕的一步(碰撞) | 一個 hapticTransient,強度 0.4,銳利度 0.2 |
HIG 的撞擊是 「a thud when two heavy objects collide」(兩個重物相撞時的悶響)46;柔和,因為美術是柔和的 |
| 開門 | 在開門過程中每個讓畫面改變的時段各一個 hapticTransient,強度 0.3、銳利度 0.6,即第 5 tick 的半開畫格與第 10 tick 的打開畫格(83 與 167 ms) |
與它所搭配的動畫相符 |
| 舉起卡片(3D 卡片) | 到達頂端時一個 hapticTransient,0.7 與 0.8 |
世界中唯一真實的物件 |
| 腳步 | 預設沒有 | 每秒 3.75 步、一走好幾分鐘,正是 HIG 所警告的過度使用 |
強度與銳利度的數值是我設定的起點,不是量測值,要在裝置上調校。在 capabilitiesForHardware() 表示無法使用 Core Haptics 的裝置上,退而使用 UIImpactFeedbackGenerator(style: .soft, view:),並在開始走路時呼叫 prepare()。
理由。 觸覺回饋的規則;第 6 節引用的 Apple 指引。46565760
檢查。 一個 -hapticLog 參數記錄每個事件。一段以腳本執行的進門,恰好記錄 2 個門事件,分別在第 5 項計數的第 5 與第 10 tick,且每一步都沒有事件;開關關閉時,完全沒有事件。
7. 影格率:60,均勻。
修改。 Info.plist 不做任何修改:不要加入 CADisableMinimumFrameDurationOnPhone,因為世界從 120 得不到任何好處。保留 RealityView;第 1 項的 tick 讓動態不受它所算繪的速率影響。若日後加入 display link,就設定 CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60),以取得 Apple 給遊戲的優先權。76
檢查。 在 iPhone 18 Pro Max 以及 iPhone Duo 的外側與內側顯示器上,於一段 60 秒的走路中記錄 SceneEvents.Update.deltaTime 的直方圖。規格書假設眾數是 16.7 毫秒;如果是 8.3,第 1 項的累加器仍會讓走路維持每格 16 tick,日誌就能證明這一點。動態日誌的 dropped 行:在正常的走路中,不論哪個更新率,都不應出現。
8. 震動,只用在一個時刻。
修改。 一次以整數 texel 計算的鏡頭震動,1 texel、翻轉 8 次、間隔 5 tick,只用於一個值得它的事件,例如在廣場上揭曉一張稀有卡片,並搭配舉起卡片的觸覺回饋。不用於門、步伐或抵達。
理由。 《綠寶石》24 次震動中最常見的一種,以及它們的克制。29
檢查。 動態日誌顯示鏡頭偏移在第 0、5 依此類推到 35 tick 之間於正負 1 texel 交替,並在第 40 tick 回到 0。
不在這份規格書裡的項目
- 格子上的鏡頭平滑或前瞻。 沒有需要平滑的跳躍,而 Game Freak 出貨時也把它的前瞻關掉了。1223
- 讓世界跑 120 赫茲。 目標是以 60 均勻配速;120 只會讓每個像素顯示兩次。6
- 預設每一步都有觸覺回饋。46
- 固定在畫面上的十字鍵。 我的建議是點擊行走,再加上一個可選的浮動搖桿。44
- 任何遊戲的開門或傳送音效、配樂片段。 音效是一種表達,Kiradex 需要自己的。
- 自行車、衝浪、冰面與水流。 app 的行走者只會走路和跑步,別無其他,上面的速度記錄下來,是為了將來情況改變時使用。17
尚未解答的問題:裝置上的時序
本節中每一個模型數字,都假設影格之間穩定為 1/60 或 1/120 秒。RealityView 在 iPhone 18 Pro Max 或 iPhone Duo 上是以 60 還是 120 算繪,以及它的 deltaTime 抖動有多大,都尚未量測;第 7 項的直方圖是第一個要跑的,而第 1 項的寫法,讓這個答案不論落在 Apple 的 iPhone 清單上哪一個更新率,都不會改變走路的任何部分。687
重點整理
如果您負責繪製美術
- 為一段距離畫走路。《綠寶石》的走路是 32 像素內兩次跨步、兩次站立,以與步伐吻合的影格數停留,較快的步態沿用同樣的畫格、縮短停留時間,而不是增加畫格,所以在任何速度下都是每 16 像素落一次腳。192
- 六張畫格的循環沒有問題,前提是引擎在固定的距離內播放它;若以固定速率播放、對上另外計時的走路,它的節奏就會與移動錯開,在我們的模型中,走路時一個循環 3.00 格,跑步時 4.50 格。6
- 《綠寶石》的門是一張關閉加三張畫格,每張在畫面上停留 84 毫秒。像 Kiradex 那樣只有三張(關、半開、開)的圖表,可以讓打開的畫格停留兩個時段來填滿四個時段,或由工坊再畫第四張;不論哪一種,都要把打開的狀態畫成值得一看的東西。12417
如果您負責打造引擎
- 以固定的 tick 推進世界,使用能整除圖塊的整數像素位移,在格上讀取輸入,並讓算繪器顯示最新的 tick。說清楚積壓會怎麼處理:我們的做法是補上最多 8 個 tick,其餘捨棄並記錄。《綠寶石》的步進表就是範本:每一張加總都是 16。216
- 在同一個 tick 把鏡頭鎖定在玩家身上,邊界與偏移都使用整數像素,這樣事後就不需要取整。先緩動再取整的做法會拖在行走的玩家後面,而且在我們程式碼的模型中,會停在差 4 到 9 像素的地方,直到下一次走路。36
- 在 iPhone 上,不要為像素動態要求 120 赫茲。Apple 為遊戲優先保障 30 與 60,RealityKit 通常以 60 算繪,而整數像素的走路在 120 下只是讓每個像素停留兩次更新。786
- 以階梯淡出,而不是平滑的斜坡。《綠寶石》的門淡出是相隔十六分之二的九個層級,最後一次混色在第 17 個影格,淡出在第 22 個影格結束;我們 18 tick 的遮罩是那條斜坡的改編,不是照抄。5
- 絕不要對承載 RealityView 的 SwiftUI view 做不透明度動畫;在它上面蓋一層遮罩。22
如果您負責設計遊戲循環
- 在《綠寶石》中,進門是一場至少 1.32 秒、不接受輸入的儀式,而跨過門檻的那一步強制步伐,正是讓它讀起來像「走進去」的關鍵。525
- 原地轉身 8 個影格,讓玩家不必移動就能面向某樣東西;32 個影格的碰撞告訴他們這一步被拒絕了。1
- 在手機上,我的建議是:預設點擊行走,如同《星露谷物語》手機版的做法;以浮動搖桿作為需要精確時的後備,如同 HIG 的建議;不放固定的十字鍵。4044
- 觸覺回饋確認的是事件,不是腳步,而且要附一個開關。46
常見問題
寶可夢的玩家走路有多快?
在《紅》《水晶》《綠寶石》中,走路的一步以 16 個影格走完一個 16 像素的格,影格率為每秒 59.7275 個影格:每格 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 上應該跑 120 Hz 嗎?
對整數像素的動態而言,不應該。一個每 60 赫茲 tick 移動一像素的世界,在 120 赫茲下只會讓每個像素停留兩次更新,在 80 下則停留不均。Apple 的 ProMotion 文章說遊戲會得到 「special priority to 30Hz and 60Hz」(30Hz 與 60Hz 的特殊優先權),iPhone app 必須設定 CADisableMinimumFrameDurationOnPhone 才能超過 60,而 RealityKit 通常以 60 算繪。以固定的 60 赫茲 tick 模擬,讓顯示器維持它原本的速率即可。67168
為什麼我的 sprite 走路時腳會滑?
通常是因為走路畫格跑在一個時鐘上,移動跑在另一個時鐘上,而兩者的長度並不吻合。Kiradex 現有的程式碼以每秒 8 張畫格播放六張畫格的循環,同時以每秒 4 格的速度走路,所以如程式碼的模型所示,一個循環涵蓋 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 開發者指南與讓您的 App 為 iPhone Duo 做好準備介紹兩片顯示器以及規格書中鏡頭所避開的折疊處;RealityKit 的空間心智模型說明 SceneEvents.Update 背後的實體與系統模型。
資料來源
-
作者的量測,2026年10月4日:
measure_gen3_motion.py,存放於作者為本文準備的證據資料夾中,對 pret 的pokeemeraldcommit731ad5b執行;它解析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_GoSouth32 個影格;門畫格停留 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與NpcTakeStep,後者以sTimer索引步進表,每個影格一筆;SetStepAnimHandleAlternation,在一步開始時設定步態的動畫與左右交替;CameraObject_UpdateMove),commit731ad5b,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();VBlankCB_Field呼叫TransferPlttBuffer;InitPlayerAvatar在InitCameraUpdateCallback(gPlayerAvatar.spriteId)之前)與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)中BeginNormalPaletteFade、帶有待傳送旗標的UpdatePaletteFade、UpdateNormalPaletteFade與IsSoftwarePaletteFadeFinishing的 Python 移植版,依其呼叫者的時程執行:門的任務在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 之後,而WarpIntoMap不早於第 79 影格、1.32 s,因為Task_WarpAndLoadMap會等待淡出變為非作用中)。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
作者的模型,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 tick 在 60 Hz 下讓每個像素停留 1 次更新,在 120 Hz 下 61 個位置中有 59 個停留 2 次更新,在 80 Hz 下停留 1 或 2 次更新,分別為 42 與 19 個位置。累加器:從 120 到 10 Hz 的十二種 iPhone 更新率各跑十秒的回呼,在積壓上限 8 個 tick 下都執行 600 個 tick 且沒有捨棄,若每次回呼上限 4 個 tick,則 12 Hz 下為 480、10 Hz 下為 400;在 60 Hz 下一次 1 s 的卡頓之後,8 tick 規則執行 8 個 tick,之後每次回呼 1 個,捨棄 866.7 ms,而保留積壓的 4 tick 上限則連續 19 次回呼各執行 4 個 tick。鏡頭的計算:fit對 393 乘 852 pt、3x 的畫面得到每個 texel 7 個螢幕像素,畫面為 168.43 乘 365.14 世界像素,半寬 84.21,向內取整的邊界為 85 與地圖寬度減 85;折疊偏移以 9 個層級從 0 變為 −37,依序經過 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 邊界 metatile,標記為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 以 2 為步長朝 72 或負 8 移動,受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。 ↩↩↩
-
作者於 2026年10月4日以唯讀方式閱讀 Kiradex 儲存庫(私人)commit
b1b78b1:Kiradex/World/PlazaRig.swift(tilesPerSecond、framesPerSecond、runPace、walk(to:)及其steps.count >= 6跑步規則、advance,其進度步長為dt * Self.tilesPerSecond * (walker.running ? Self.runPace : 1) / length、animate、place、fit,它把畫面的螢幕像素除以整數的pixelScale、follow,它以displayScale / pixelScale換算折疊處的點數,以min(1, dt * 6)緩動並在之後取整,其限制只有在 Duo 半開時才讓鏡頭越過地圖邊緣進入樹林,距離為beyondMargin,即 12 格,而build不論折疊狀態都會鋪設樹林、ripple,它在有水的地圖上讓地面的水每秒換 4 次畫格、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」)、openDoor以SpriteSheet.bundled(name, columns: 3, rows: 1)載入該圖表,先顯示第 1 欄再顯示第 2 欄、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(commitd2704a6,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 格),提案的 tick 迴圈取自同一支腳本;《綠寶石》的淡出取自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」;Celeste 以 320 乘 180 算繪再乘以六;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 獨立遊戲高峰會(Independent Games Summit)演講的修改版(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的BeginNormalPaletteFade、它自己對UpdatePaletteFade的呼叫、它對調色盤記憶體的CpuCopy32與sPlttBufferTransferPending = FALSE;UpdatePaletteFade,在該旗標設定時立即返回,並在每次更新後依gPaletteFade_selectedPalettes設定旗標;清除旗標的TransferPlttBuffer;UpdateNormalPaletteFade;IsSoftwarePaletteFadeFinishing),2026年10月4日存取,https://github.com/pret/pokeemerald/blob/master/src/palette.c。 ↩↩ -
pret,
pokeemerald/src/fldeff_flash.c(sTransitionTypes及其進出MAP_TYPE_UNDERGROUND的 16 列,每列有一個進入旗標、一個離開旗標與一個轉場常式;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 的pokeemerald731ad5b中data/**/*.inc全部的special ShakeCamera呼叫,以及其前方的四個setvar值。輸出存為measure_gen3_shake.out.txt(9 個檔案中的 24 次呼叫,表中的八組參數及其次數)。 ↩↩↩↩↩↩ -
pret,
pokered/home/overworld.asm(OverworldLoop、OverworldLoopLessDelay、wWalkCounter、AdvancePlayerSprite、DoBikeSpeedup,以及 180 度轉身的註解),commitd2704a6,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),commit5beda23,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」一節中,26 像素的人物在 TestFlight build 34 被換成 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)」之下的鏡頭更新,以及CameraTargetgetter),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日彙整,記錄搜尋過但沒有找到的內容:《星之海》、《風來之國》或 CrossCode 鏡頭的一手資料;Maddy Thorson 談鏡頭的演講或文章;像素複刻版確切的觸控方案;https://terraria.wiki.gg/wiki/Mobile_version 上 Terraria 手機版的移動方式;以及回傳 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日,關於為手機版加入控制器支援的 Final Fantasy Pixel Remaster 更新,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《人機介面指南》,「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《人機介面指南》,「Playing haptics」(最佳做法、自訂觸覺回饋與 iOS 的撞擊類別;文中引文),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;
init(style:view:)列在「Initializing the feedback generator」之下,init(style:)列在 Deprecated 之下),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。 ↩