Whisper Isle v0.1.3 遊戲連結(PC Only):whisperisle.app
昨天在 Day 0 提到,我找了 Claude Code 當主力 CTO,打算在瀏覽器裡 vibe 出一個 MMO ARPG。
很多人看社群分享 AI 寫程式,畫面通常很美好:給一句 prompt,AI 吐出幾百行代碼,在瀏覽器裡跑起來好像有模有樣。但實務上,如果你真的拿它來做一個即時多人連線系統,而且放任它「自由發揮」,那可能難以長期支撐。
AI 寫 code 有個天性:它永遠傾向用阻力最小、最扁平的方式解決眼前的問題(簡單說就是偷懶,或者說節省算力)。你叫它做點擊移動,它最順手的寫法就是在前端把人物座標改掉,發個封包告訴伺服器「我在 (x, y)」;你叫它做血條,它就會在 UI 元件裡宣告一個 let currentHp,你叫它做聊天室,它就把 DOM 按鈕事件直接綁在遊戲渲染引擎的 Sprite 物件上。
前三天看起來神速,第七天功能一多,你的整個 codebase 就會變成一盤誰也動不了的義大利麵條(Spaghetti code)。當上下文越來越大、依賴越來越亂,AI 就會開始出現幻覺、改 A 壞 B,最後重構的成本可能高到無以為繼。
所以在專案開工的第一個 commit 裡,除了 package.json 就是 CLAUDE.md,為了確保整個施工流程盡可能順暢(畢竟你要 vibe 速度還是很快的,只能盡量變做邊修正方向),施工規則還是要先制定下來。
1. Client 端只送「意圖」,不送「結果」
這是做 MMO 最核心的骨架,也是 AI 最容易偷懶破功的地方。
AI 天生缺乏分散式系統與防作弊的直覺,它的預設思維往往是「單機模式」:前端算好位移,再同步給後端。如果放任它這樣做,後面只要加上怪物的攻擊判定、地圖碰撞,整個架構就會直接崩塌。因此第一天在協定層(packages/shared)就把訊息定義鎖死:
// packages/shared/src/index.ts
/** 玩家移動速度(邏輯 px/s;server 權威模擬唯一使用點,client 不得自算位移) */
export const PLAYER_SPEED = 220;
/** server 固定模擬 tick(Hz) */
export const SIMULATION_TICK_RATE = 20;
/**
* Client → Server 訊息名。
* 鐵律:client 只送意圖(「我想去 (x,y)」),不送結果(「我在 (x,y)」)。
*/
export const ClientMessage = {
MoveTo: "moveTo",
} as const;
/** MoveTo payload=目的地世界座標(意圖;server 端 clamp 進世界邊界) */
export type MoveToPayload = Vec2;
客戶端永遠只能發送「意圖」——例如 moveTo(x, y) 代表「我想走向這裡」。至於走不走得到、移動路徑有沒有被障礙物擋住、每一步走多快,全部由伺服器每秒 20 次(20Hz)的 fixedTick 說了算:
// server/src/rooms/WorldRoom.ts
// 接收意圖:只記錄目標點,不改座標
messages = {
[ClientMessage.MoveTo]: (client: Client, payload: MoveToPayload) => {
const player = this.state.players.get(client.sessionId);
if (!player) return;
if (!Number.isFinite(payload?.x) || !Number.isFinite(payload?.y)) return;
player.target = {
x: clamp(payload.x, 0, WORLD_WIDTH),
y: clamp(payload.y, 0, WORLD_HEIGHT),
};
},
};
// 伺服器唯一寫入點:固定 tick 等速推進
private fixedTick(dtMs: number) {
const step = (PLAYER_SPEED * dtMs) / 1000;
this.state.players.forEach((player) => {
if (!player.target) return;
const dx = player.target.x - player.x;
const dy = player.target.y - player.y;
const dist = Math.hypot(dx, dy);
if (dist <= step) {
player.x = player.target.x;
player.y = player.target.y;
player.target = null;
return;
}
player.x += (dx / dist) * step;
player.y += (dy / dist) * step;
});
}
2. 狀態單向(Colyseus State 是唯一事實來源)
AI 寫前端時最愛做一件事:為了讓畫面反應更靈敏,在各個組件裡面宣告本地變數當快取(例如在 UI 層存一份 let myHp = 100、在場景層存一份怪獸清單)。
這在單機應用或許無傷大雅,但在即時多人連線裡就是災難的源頭。一旦發生網路抖動或丟包,前端的本地變數跟後端的真實狀態就會產生漂移。最後玩家就會看到怪物明明血條空了卻打不死、或是自己血條滿的卻突然暴斃。
所以立下的第二條防線:Colyseus State 是唯一的事實來源(Single Source of Truth)。
3. UI 代碼與遊戲世界徹底隔離
在 Web 遊戲開發中,畫面通常是由兩個完全不同的世界拼起來的:
底下的 Canvas/WebGL 遊戲世界(地圖、角色、怪物、粒子特效)。
上面的 DOM 介面(聊天框、背包格、血條、系統選單)。
如果沒有立規則,AI 最喜歡抄捷徑:在 HTML 按鈕的 click handler 裡直接伸手去摸 Pixi.js 的 Sprite 改屬性;或是反過來,把遊戲世界的怪物物件整包傳給 UI 元件當 props。這兩者一旦產生雙向依賴,未來只要你想微調 UI 佈局,整個遊戲的底層渲染就會跟著被污染。client/src/ui/ 與 client/src/world/ 嚴禁互相 import 內部模組。
┌─────────────────── 客戶端 (Client) ───────────────────┐
│ │
│ [ DOM UI 殼 ] [ Pixi Canvas 世界 ] │
│ (血條 / 快捷列 / 聊天) (地圖 / 角色 / 怪物) │
│ │ │ │
│ └─── 嚴禁互相 import 內部模組 ───┘ │
│ │ │
│ ▼ │
│ 只送意圖:ClientMessage.MoveTo │
└─────────────────────────┼──────────────────────────────┘
│ WebSocket
┌─────────────────────────┼──────────────────────────────┐
│ ▼ │
│ [ Server: WorldRoom 權威模擬 (20Hz fixedTick) ] │
│ - 檢查邊界與障礙物 │
│ - 等速推進座標 │
│ │ │
│ ▼ │
│ 單向廣播:WorldState (唯一事實來源) │
└─────────────────── 伺服器 (Server) ───────────────────┘
