懶人包
- 起點是一支 AI 生成的 IG Reel:女生踩長板從山路衝向海岸城市,第一人稱、高速、現實拍不到的景。我把同一種爽感做成能玩的網頁遊戲 Coastal Bomb。
- 技術是 OutRun 那一代的「偽 3D 道路」:把路切成一段段,每段記彎度和高度,投影到螢幕上畫成梯形。不用 three.js,單一 HTML 226 行、13 KB、零依賴。
- 彎道只靠累加:每往前一段,水平偏移量加上這段的彎度,畫面上的路就自然彎過去。這是 Lou Gorenfeld 的 Pseudo 3D 教學頁講的做法。
- 玩法只有一條規則:彎道離心力跟速度平方成正比,橫向偏移超過路寬 1.25 倍就摔車。我用 Playwright 驗證,放手不操作約 500 m 會被甩出;自動駕駛跑 25 秒滑了 5,579 m、最高 85 km/h,沒有 JS 錯誤。
- 想自己做,從 jakesgordon 的 javascript-racer(MIT)v1 到 v4 按順序讀最快;我的版本是把它的投影公式縮成單檔,再加上下坡重力和離心力。
這篇記錄我怎麼把一支 AI 滑板短片變成一個瀏覽器小遊戲:偽 3D 道路的原理、我實際寫的程式、用來確認「真的能玩」的自動測試,以及這種做法的邊界。遊戲已經上線,手機打開 html.yanchen.app/coastal-bomb 就能玩。
這支海岸下坡滑板 Reel 在演什麼?
原片是 IG 帳號 lofi_horizon1 在 2026-10-21 發的 Reel,我在 2026-10-27 讀到 676 讚、7 則留言。畫面是一個長髮女生踩著藍色長板,從綠色山坡的柏油路往下衝,左手邊是海和白色浪花,山腳下是一整片海岸城市,前景草地拉出很重的動態模糊。
文案是通用的互動模板:「Would you dare to bomb this epic hill? Let us know in the comments!」,後面接一串 hashtag。人物髮絲的畫法、城市像拼貼出來的質感,加上這種文案,我判斷它是 AI 生成的影片,帳號沒有標示,所以這是我的判斷,不是帳號自己承認的。
這類片吸引人的地方很單純:第一人稱、速度感、一個現實中很難拍到的視角。影片只能看,我想知道把它變成能操作的東西要花多少力氣。AI 影片本身怎麼生,我在拆解賽博龐克 Reel 那篇寫過,這篇只談遊戲。
偽 3D 道路是什麼?為什麼不直接用 three.js?
偽 3D 道路是 1980 年代賽車街機的畫法,OutRun 是代表作。世界裡沒有真正的 3D 模型,只有一條被切成很多小段的路。每一段記兩個數字:這段往左或往右彎多少(curve)、這段的高度(y)。每一幀從鏡頭腳下開始往前算,把每段路的前後兩條邊投影到螢幕上,中間填一個梯形,近大遠小,就有了一條延伸到地平線的路。
我選它而不是 three.js,理由是這個遊戲要的東西它全部都有:
| 需求 | 偽 3D 道路 | three.js |
|---|---|---|
| 一路往前的道路、彎道、下坡 | 資料結構本身就是這個 | 要自己建網格、貼圖、相機路徑 |
| 單檔、零依賴、手機直接開 | 一個 <canvas> 加 2D context | 要載入函式庫 |
| 能在課堂上一行一行講完 | 核心投影只有三行 | 場景、材質、光源都要講 |
主要參考兩個來源。Lou's Pseudo 3d Page 是 Louis Gorenfeld 寫的原理說明,從 raster 道路講到 3D 投影的路段,OutRun 的路面晶片也有專門一節。jakesgordon/javascript-racer 是 MIT 授權的 JavaScript 實作,分成直線、彎道、坡道、完整版四個階段,配套文章在 jakesgordon.com(2012 年)。
一段路怎麼投影到螢幕上?
投影公式來自相似三角形:螢幕上的大小等於「鏡頭到投影面的距離」除以「物體到鏡頭的距離」。jakesgordon 的寫法是 scale = cameraDepth / camera.z,cameraDepth 由視野角算出:d = 1/tan(fov/2)。
我的版本把常數直接寫死成 FOV_D = 0.84,換算回去 2 × atan(1/0.84) 約等於 100°,跟 jakesgordon 預設的 100° 視野一樣:
function proj(wx, wy, dz, camX, camY) {
const scale = FOV_D / dz;
return {
x: W / 2 + scale * (wx - camX) * W / 2, // 路中心的螢幕 x
y: H / 2 - scale * (wy - camY) * W / 2, // 螢幕 y,往下為正所以要減
w: scale * ROAD_W / 2 * W / 2, // 這一段路的半寬
};
}
dz 是這段路離鏡頭多遠,越遠 scale 越小,路就越窄、越靠近地平線。camX 是玩家在路上的左右位置,玩家往右移,整條路在螢幕上就往左畫。
彎道和下坡是怎麼「累加」出來的?
彎道是這個技術最漂亮的地方。Lou 的頁面描述它是「路中心每一行往左或往右偏移的量穩定遞增」,只要用加法。寫成程式就是兩個變數:
let dx = -b.curve * pct, cx = 0; // 從鏡頭腳下那段開始
for (let n = 0; n < DRAW; n++) {
const s = segs[(base + n) % segs.length];
const p1 = proj(cx, s.y, dz1, camX, camY);
cx += dx; // 中心偏移量累加上目前的斜率
dx += s.curve; // 斜率再累加上這段的彎度
const p2 = proj(cx, s2.y, dz2, camX, camY);
// ...
}
curve 是二次導數,dx 是一次導數,cx 是位置。一段 curve = 3 的路,往前畫時偏移量是 3、6、9、12 這樣加上去,螢幕上看起來就是一個越來越急的彎。玩家在彎道中間時,開頭的 -b.curve * pct 把鏡頭所在那段已經走過的部分扣掉,彎道就不會一格一格跳。
下坡更直接:產生賽道時,每一段的 y 比前一段低一點,鏡頭高度永遠是「玩家所在位置的路面高度 + 900」。遠方的路比鏡頭低,投影出來就落在畫面下方,看起來就是往下衝。
賽道用固定種子(20261027)的亂數產生:先 80 段直線起步,再接 40 個彎道區塊,每個區塊 60 到 149 段、左右隨機、越後面彎度上限越高。總共 4,360 段,每段 200 單位長,跑完會繞回起點。
為什麼要先由近到遠算、再由遠到近畫?
上坡、下坡時,遠處的路可能被近處的坡頂擋住。jakesgordon 的做法是由近往遠畫,用一個 maxy 記住「目前畫到螢幕多高」,後面的段落如果投影結果沒有比它更高,就是被擋住了,直接跳過。
我多做一步:先由近到遠跑一次,只算座標和判斷遮擋,存進陣列;再由遠到近真正上色。
這樣做的理由是霧和樹。遠的段落要往天空色混,越遠越淡;路邊的樹會蓋過後面的路,必須讓近的東西最後畫。先算後畫,一次就同時解決遮擋和疊層順序。
玩起來的手感從哪裡來?
畫面只是一半,另一半是物理。我只放了三條規則:
speed += (GRAVITY * (0.6 + slope * 2) - speed * DRAG - (brake ? BRAKE : 0)) * dt;
x += (right - left) * dt * 2.4 * (0.35 + sr); // 轉向,越快轉得越多
x -= dt * sr * sr * seg.curve * CENTRIFUGAL; // 離心力,跟速度平方成正比
- 重力:坡越陡加速越快,阻力(
DRAG)跟速度成正比往回拉,速度上限換算成 85 km/h。 - 離心力:
sr是目前速度佔最高速的比例。速度加倍,彎道把你往外推的力變四倍,所以高速過急彎一定要提早壓方向或腳煞。 - 出界:橫向位置
|x|超過 1.25(路寬一半是 1)就摔車;左邊翻進草坡,右邊衝出護欄。速度掉到接近零也算結束。
畫面元素都是 Canvas 2D 直接畫的:天空和海用漸層,遠方城市是 70 個隨機高度的白色長方形,路邊每 6 段一根護欄柱,約 18% 的段落有一棵樹。畫面下方是第一人稱的滑板和兩隻腳,轉向時板子會跟著傾斜 0.1 弧度。
速度超過最高速的 55% 會出現放射狀的速度線,越快越明顯;畫面也會依速度平方輕微晃動。這兩個效果沒有改變任何玩法,只是在模仿原片那種動態模糊帶來的緊張感。
怎麼證明它真的能玩?
遊戲寫完,我沒有只靠自己玩幾次就算數。我在頁面上留了一個唯讀的 window.__cb.state,讓 Playwright 讀目前的速度、距離、橫向位置和彎度,然後跑兩支測試:
run_test.py:放手不操作,看會不會被彎道甩出;再分別按住右、左、下,確認轉向和煞車有反應;最後收集頁面上的 JS 錯誤。autopilot.py:一個很笨的自動駕駛。每 50 ms 讀一次狀態,用x + curve × 0.08判斷要往哪邊壓,速度超過 70 km/h 又遇到急彎就按煞車,最多跑 500 次(約 25 秒)。
after 15s idle: {'playing': False, 'speed': 25, 'dist': 503, 'x': 1.252, 'curve': 1.92, 'segs': 4360}
steer x: 0 ->right 0.608 ->left -0.017
brake speed: 38 -> 10
errors: []
autopilot final: {'playing': True, 'speed': 85, 'dist': 5579, 'x': 0.098, 'curve': -1.2, 'segs': 4360} max km/h: 85
| 情境 | 結果 | 代表什麼 |
|---|---|---|
| 放手 15 秒 | 503 m 被甩出,x = 1.252 | 不操作一定會輸,難度有作用 |
| 按右 0.3 秒、再按左 0.3 秒 | x 從 0 到 0.608 再回到 -0.017 | 轉向有反應,而且能拉回來 |
| 按住煞車 0.7 秒 | 38 → 10 km/h | 腳煞有效 |
| 自動駕駛 25 秒 | 5,579 m,最高 85 km/h,仍在路上 | 會操作就能一直滑下去 |
| 全程 | errors: [] | 沒有 JS 錯誤 |
開發時遇過一個 bug:第一幀算出來的 dt 是負數,pos 跟著變成負的,segs[-1] 讀到 undefined,畫面直接報錯。修法是一行,把 dt 夾在 0 到 0.05 秒之間:
const dt = Math.max(0, Math.min(.05, (now - last) / 1000));
上限 0.05 秒是另一層保護:分頁切到背景時 requestAnimationFrame 會暫停,切回來的第一幀 dt 可能是好幾秒,不夾住的話角色會一次跳過一大段路。
這個做法的限制在哪裡?
- 只能往前看:鏡頭綁在路上,看不到原片那種往左轉頭看海的鏡頭。要做自由視角就得換真 3D。
- 沒有障礙物和對手:目前唯一的失敗方式是出界。jakesgordon 的 v4 有路邊精靈圖和車流碰撞,是下一步最直接的參考。
- 畫風是色塊:原片的吸引力有一半來自寫實的海景。背景可以換成 AI 生的圖,但要切成能水平捲動的長條,才能在過彎時做視差。
- 手機觸控是半邊判斷:按住螢幕左半或右半轉向,兩指同時按就煞車,沒有做傾斜感應。
- 物理參數是手調的:重力、阻力、離心力係數是我邊玩邊改出來的,不是根據真實長板的數據。
想自己做一個,要從哪裡開始?
- 先讀 Lou's Pseudo 3d Page 的曲線段落,弄懂「累加偏移」為什麼會變成彎道。
- 照 javascript-racer 的
v1.straight.html→v2.curves.html→v3.hills.html順序讀,每一版只多一個概念。 - 打開 Coastal Bomb 按右鍵看原始碼,整個遊戲就是一個 HTML 檔,可以直接存下來改常數。最值得先動的是
CENTRIFUGAL(離心力)和SLIDE_OUT(出界門檻),這兩個數字決定遊戲難不難。 - 改完寫一支像
autopilot.py的自動測試,確認「不操作會輸、會操作能贏」兩件事同時成立,再給別人玩。