不只是 Canvas:
React + Phaser
的前端遊戲架構實戰
該記住的進 store,一次性的走 event bus,60fps 的留在 Phaser。
Leo Kuo
共同創辦人
作品《開拓者》
獲放視大賞銅獎
前端工程師
WhatsApp CRM 新創
React 產品前端
軟體工程師
貨運物流 SaaS
大型 web 產品開發
全職遊戲開發
單人獨立開發《終局棒球》
React + Phaser + Capacitor
帶著前端技能做遊戲。平常也參加 game jams(gamedev.js、js13kGames)。
- 用網頁技術做遊戲的取捨——什麼時候適合、什麼時候不適合
- 適合的時候,現代前端框架要怎麼跟遊戲渲染畫面搭配
一開始做《終局棒球》時,因為比較熟悉網頁技術,所以就選了 canvas + DOM 的結構,後來發現,這套網頁遊戲架構其實也有些好處。
選工具之前,先回答三個問題
什麼遊戲?
2D UI 密集?上萬 entity 的模擬?還是 3D?
什麼平台?
Web?Mobile?還是 Steam / 主機?
什麼階段?
prototype?還是長期營運的產品?
比較之前先定位,沒有定位的話,都是信仰之爭。
為什麼用網頁技術做遊戲
通路就在瀏覽器
要做的本來就是網頁遊戲,貼個連結就能玩
最快從想法到能玩
想快速驗證 prototype 的時候
既有技能直接複用
網頁工程師不用重學,一份 code 出 Web・PC・Mobile
反過來說:要做 3D、注重效能、或要發布到主機——這些 web 可能不合適,考慮用引擎比較快。
為什麼這條路的 prototype 特別快
- AI 執行力最強的地盤——TypeScript 語料庫最大;專案狀態 100% 是文字,AI 讀得懂、也改得動全部;agent 能自己「寫 → 跑 → 看截圖 → 修」
- 平行開發是預設——git worktree × N 個 dev server × N 個 agent
- 部署 = 丟一個連結——build 出來就是網頁,貼給任何人點開就能玩
// 對照組:AI 讀得懂語法,但生不出正確的值 --- !u!114 &1660057541 MonoBehaviour: m_Script: {fileID: 11500000, guid: fc0d4c8e2b3a4f1, type: 3} m_Father: {fileID: 963194228}
業界作法:AI 不直接編 YAML,改走 MCP 驅動編輯器 API,代價是需要開著編輯器:重、改完要 domain reload 才生效
編譯與 domain reload 打斷節奏、給人試玩要下載安裝、每份 worktree 各自重建 Library(Unity)、GDScript 語料稀少(Godot)。
網頁技術做的遊戲,可以走多遠?
- 2020 年,Poncle 用 HTML5 / canvas 技術開始寫
- 2022 年,它成了年度爆款——Steam 壓倒性好評
- 爆紅的初版,就是網頁技術做的
Canvas 與 DOM,是兩種相反的繪製哲學
- requestAnimationFrame 當節拍器,遊戲迴圈的慣例是每幀 重畫整個場景
- API 不保留「物件」:沒有元素、沒有 layout、沒有事件目標,只有像素
- HTML5 遊戲引擎(Phaser、PixiJS…)= 在這上面自建 scene graph、批次渲染、input 判定
- 這正是 60fps 遊戲該有的模型
- 瀏覽器持有元素樹,layout / paint / composite 增量處理
- 附送三十年累積:文字排版、自動換行、字型、捲動
- 表單、responsive web design
- 這正是產品介面該有的模型
硬要跨界,兩邊都是在重造輪子
- 文字換行自己算、輸入框自己刻、捲動清單自己寫
- 中文 IME 輸入在 canvas 裡幾乎無解
- 設定頁、商店、帳號流程。UI 一多就是無底洞
- 上千個會動的元素 = layout / paint 全面爆炸
- 重畫由瀏覽器排程,掉幀時沒有「這幀少畫一點」這個選項
- 每幀配置節點與字串引來 GC 停頓、layout 成本隨結構浮動
canvas 管遊戲世界(擅長每幀重畫),DOM 管產品介面(擅長排版互動)。
當遊戲不只是 canvas 裡的一段互動——
畫面一多、流程一長,兩個世界開始
搶狀態、搶 input。
邊界切分,就成了架構的關鍵。
先看 demo,再拆解
- 一個 30 秒的小遊戲——React 做介面、Phaser 跑遊戲
- 藍框是 React、橘框是 Phaser、右側是兩個世界的即時對話
本章起,遊戲引擎以 Phaser 為例;前端框架同理可換成 Angular / Vue / Svelte。
github.com/leokuo0724/phaser-stacks
同一個螢幕,兩個世界怎麼疊?
/* 遊戲畫布:墊在最下面 */ .game-canvas { z-index: 0; } /* UI 層:蓋在上面,但預設「穿透」 */ .ui-layer { z-index: 1; pointer-events: none; } /* 只有互動元件自己接住事件 */ .ui-layer button { pointer-events: auto; }
- 不設穿透會怎樣?UI 層是蓋住整個畫面的透明 div——所有點擊都被它吃掉,玩家永遠點不到遊戲
- 所以整層設 pointer-events: none:事件穿過 UI 層、落到 canvas
- 需要互動的按鈕各自 opt-in auto——點按鈕是 React 的、點畫面是遊戲的,零事件轉發程式碼
跨界資料流切分
選單・計分 UI・modal・流程切換
產品介面的世界
scene・sprite・physics・input
60fps 的世界
Redux —— 該記住的狀態
status / score / combo…
event bus —— 一次性的瞬間
ui:celebrate / game:hit…
守則:react/ 的程式碼不准 import game/,game/ 也不准 import react/——要溝通,只能經過 store 與 event bus。
該記住的進 store,
一次性的走 event bus。
該記住的:之後還有人要讀的狀態
// store/game/slice.ts —— 單一事實來源 { status: "playing", // 流程 score: 240, // UI 要顯示 combo: 6, // UI 要顯示 timeLeft: 12, // 兩邊都關心 highScore: 512, // 要持久化 }
- 特徵:低頻變動、多方讀取、值得持久化
- React 用 useAppSelector 讀、Phaser 透過橋接層讀寫
- 兩邊都不持有副本、只讀寫同一份,不用互相同步
一次性:發生完就不需要存的「瞬間」
// shared/event-keys.ts —— 型別化契約 type GameEvents = { // React → Phaser(一次性指令) "ui:celebrate": void; // Phaser → React(一次性事實) "game:hit": { x; y; points; combo }; // 就這些——status 家族全走 store };
- 特徵:發生完就沒了,不值得存——存了反而要煩惱清除
- mitt<GameEvents>:兩層共用同一份型別,編譯期保障
StoreManager:遊戲程式碼唯一碰 store 的檔案
// game/managers/store-manager.ts class StoreManager { select<T>(fn: (s: RootState) => T): T { return fn(store.getState()); } dispatch(action: Action) { store.dispatch(action); } subscribe(fn: () => void) { return store.subscribe(fn); } }
- 把依賴刻意收窄到一個檔案——整個 game/ 與 store 的往來都經過這裡
- 想換 Zustand / Jotai?只改這一個檔
- code review 時 grep 一下,就知道有沒有人繞過邊界
接線:React → Phaser 的兩條路
// React → Phaser:暫停選單改難度,遊戲立刻變速 // React — PauseModal.tsx dispatch(setDifficulty("hard")); // Phaser — GameScene.create() this.unsubscribe = storeManager.subscribe(() => { const d = storeManager.select(selectDifficulty); if (d === this.prev) return; // 自己 diff this.applyDifficulty(d); // 即時生效 });
status 家族同路——prevStatus 分辨 start / resume;cleanup 記得退訂
// React → Phaser:按鈕驅動煙火效果 // React — Hud.tsx <button onClick={() => emitter.emit("ui:celebrate") }>🎉</button> // Phaser — GameScene emitter.on("ui:celebrate", () => this.handleCelebrate() // 噴 3–5 波粒子 );
「慶祝一下」不需要被記住——硬存 store 就得煩惱誰去清 isCelebrating
Demo:暫停改難度 → 繼續立刻變速(左);按 🎉 噴粒子、event log 飄事件(右)。
接線:Phaser → React 的兩條路
// Phaser → React:打擊累加分數,HUD 自動重繪 // Phaser — GameScene(經 StoreManager) storeManager.dispatch( registerHit({ base, combo }) ); // React — Hud.tsx const score = useAppSelector(selectScore); return <div>{score}</div>; // 自動重繪
分數之後要顯示、要存檔——該記住的;React 這端框架自動 re-render
// Phaser → React:打擊驅動 combo UI 顯示 // Phaser — GameScene emitter.emit("game:hit", { x, y, points, combo }); // React — ComboToast.tsx useEffect(() => { const onHit = ({ combo }) => combo % 5 === 0 && show(combo); emitter.on("game:hit", onHit); return () => emitter.off("game:hit", onHit); }, []);
里程碑 toast 亮 1 秒就消失——一次性,不進 store;unmount 記得 off
Demo:連擊看分數跳動(左);combo 到 5 的倍數彈「COMBO ×5!」(右)。
四種接法
| 該記住的(之後要讀)→ store | 一次性(不需存)→ event bus | |
|---|---|---|
| Phaser → React | dispatch:registerHit 改 score,UI 自動重繪 | emit:game:hit → Combo 里程碑 toast |
| React → Phaser | subscribe:改 difficulty,場景即時調速(接線 B) | emit:ui:celebrate 噴粒子(接線 A) |
「這個值,canvas 外有人要嗎?」——沒有,就留在 Phaser(sprite 位置、velocity、tween、timer 每幀都在變,一秒 60 次 dispatch 沒有意義);有,才看要不要記住:要 → store、一次性 → bus。
狀態管理做對,存檔就好處理
- 該記住的都集中在 store → 掛一個 auto-save middleware,變動就自動寫出去;寫到 localStorage、裝置端 Preferences 還是雲端,只是換這一層的實作
- 啟動時讀回 initial state → reload 之後,進度、高分、設定全部都在
- 遺失的只有 canvas 裡的瞬時狀態——那些本來就該重算重繪
同一套機制在三個地方:開發中的整頁 reload・玩家裝置的意外重啟・載入特定 dataset 重現場景來測試
一份 code,三個世界
瀏覽器
原生平台,URL 即玩、秒載入、可 embed
Capacitor / Cordova
iOS / Android
VS 手機版當年就是這條路(Capacitor
官方文件點名)
Electron / Tauri
桌面應用
VS 的 Steam 版當年就是網頁版 + Electron
吸血鬼倖存者在網頁版時代,就用這一套吃下了三個通路
重點整理
想快速驗證想法時,這條路的優勢最大:部署即連結、AI 與平行開發。
要長期營運時看訊號:通路在瀏覽器、效能可控
→ 留下;3D/重模擬/上主機/美術要自己動手
→ 考慮換引擎。
UI 密集(選單、設定、商店、表單、中文輸入——就拉進來),考慮結合 DOM
只有幾顆按鈕的話,純 canvas 就夠,不用扛一整個框架。
該記住的 → store | 一次性的 → event bus | 60fps 的 → 留在 Phaser
2023 年,Vampire Survivors 移植到 Unity
官方理由:效能、硬體相容性、上主機
撞到訊號就搬家,不過網頁版早已幫它賺完驗證與爆紅。
Thank You
Demo 模板
phaser-stacks
Threads
@kuo0724
《終局棒球》——React + Phaser + Capacitor,TestFlight 測試中
Capacitor:把你的網頁裝進手機 App
App 裡放著一個系統瀏覽器元件(WebView:iOS 的 WKWebView、Android System WebView),你的 JS / HTML / CSS 就跑在裡面;打包時整包 web 資產塞進 app 本體、從本地載入——不是連線去開一個網址。
- 整合只動 ~2 個檔案——架構層完全不知道自己在 App 裡
- 原生能力用 plugin 補:@capacitor/preferences(存檔)、@capacitor/haptics(震動)
- WebView 裡的 JS 有完整 JIT、canvas/WebGL 有 GPU 加速——天花板在單一 JS 主執行緒
APP 更新的三種模式
換的是 app 本體:原生殼、plugin 的原生碼、Capacitor runtime。binary 變了 → 必走商店審查
app 本體不動,只把包在 app 裡的 web 資產換新:連線下載新 bundle 存到本地、下次啟動切換。免審查——明文允許 ①
WebView 直接連遠端網站,資產不在 app 裡。離線即死、全看網速,還有拒審風險 ②。不要用
原生層走商店,但遊戲本體活在 JS 層
邊界切分切得好,連發版策略都受益。
① Apple 開發者合約 §3.3.1(B)(舊編號 3.3.2):App
不得下載可執行碼;唯一例外=由內建 WebKit / JavascriptCore 執行的直譯碼,且不得改變 App 主要用途
② App Review Guidelines §4.2 Minimum
Functionality:App 須超越「重新包裝的網站」——純包網址的殼 app
常據此被拒