wololoEN

沒有伺服器的 io 遊戲:我們做了哪些取捨

墨圈完全在瀏覽器裡離線執行。我們怎麼用機器人模擬多人連線、為什麼不用框架,以及迴圈、存檔、效能與測試上的取捨。

說到 io 類遊戲,通常會想到一台即時連線的伺服器:一張大地圖、幾十個真人,誰都可以隨時加入。我們規劃的三款 io 類遊戲反過來做,全部在瀏覽器裡離線執行,不需要伺服器,也不需要帳號。目前能玩的是墨圈,同一份計畫裡的另外兩款還是草稿,沒有上線。這篇整理三款共用的工程與產品決策,例子都取自墨圈;墨圈本身的規則怎麼來的,寫在〈墨圈的設計〉。

先定義「io 類」

我們把 io 類拆成六個特徵,三款都要滿足:

  1. 秒進場:輸入暱稱(可以留空),按一下就玩,沒有帳號,也沒有教學關。
  2. 從很小開始長大:每局從最弱開始,靠場上的資源和擊殺成長。
  3. 一條命:死了這局就結束,按 Enter 立刻重開。
  4. 人人上排行榜:右上角即時列出前 10 名,自己不在前 10 名也會附在最後一列。
  5. 操作極簡:滑鼠方向加一個動作鍵,兩分鐘內就懂。
  6. 大魚有弱點:一定要有讓小玩家翻盤的機制。

我們只沿用這個類型的共通慣例,規則本身是原創的,不做既有作品的變體。

用機器人填滿場地,但留一道接縫

決定:不架伺服器。 每局由約 20 到 28 個 AI 機器人頂著像真人的暱稱填滿場地,墨圈是 24 個。暱稱中英混雜,模仿 io 遊戲大廳的樣子:小明、Kai、不要吃我、xX_Nova_Xx。

理由有兩個。這個 repo 裡的遊戲都是純靜態檔案,不需建置,雙擊 index.html 就能玩,架 WebSocket 伺服器會打破這個慣例。再來,io 類的樂趣主要來自「場上有很多跟你一樣在成長的對手」,機器人做得好,就能保留大部分的樂趣。

放棄的是真人連線和房間配對,但我們留了一道接縫:模擬層只認一種輸入結構 { ax, ay, act },也就是瞄準方向,加上動作鍵有沒有按下。玩家和機器人都透過它驅動同一種實體:

// 簡化自 game.js
for (const b of world.brushes) {
  inputs[b.id] = b.isPlayer
    ? playerInput(b)              // 滑鼠、鍵盤或觸控 → { ax, ay, act }
    : Bots.input(world, b, dt);   // 機器人的大腦 → 同樣的結構
}
world.step(dt, inputs);

以後要接連線,把機器人的大腦換成遠端傳來的輸入,規則的部分不用動。

像人,而且打得贏

完美瞄準的機器人既無聊又讓人挫折,所以我們刻意給它們破綻:

  • 不是每一格都在想。機器人一秒大約只重新判斷四到七次,中間照目前的計畫走,等於有了反應時間。
  • 瞄準有誤差,大小依個性而定,技術差的會晃。
  • 有個性。每個機器人有 aggression(侵略)、caution(謹慎)、skill(技術)三個參數,於是有膽小的農夫型、愛追人的獵人型,也有專撿尾刀的投機型。這樣才會出現「那個 Kai 一直來偷我」的故事感。
  • 看不到全圖。機器人只感知自己附近一定範圍內的東西。
  • 死後 2 到 6 秒重生,維持場上人數。墨圈的機器人重生時有四分之一的機率是中等大小的「老手」,排行榜不會一開場就全是小不點。

規格上的數字只能當起點。三款照規格寫出來的機器人都太兇,得拉高進攻門檻、加上冷卻。墨圈另外還碰到反方向的問題:機器人一被包圍就立刻逃走,封印少得可憐,最後是加上察覺延遲才調回來。

原生 JS 加 Canvas 2D,不用框架

純 HTML、CSS、JS,用 <script> 標籤依序載入,不用 ES modules。原因很實際:ES modules 從 file:// 開啟會被瀏覽器的 CORS 規則擋下來,而我們要每款遊戲都能直接雙擊開啟。

繪圖用 Canvas 2D,音效用 WebAudio 即時合成,不放任何音檔。字型從 Google Fonts 載入,並附上系統字型備援,離線時照樣能玩。放棄的是 TypeScript、打包工具和 WebGL:Canvas 2D 撐得住一千多個小物件,用不到 WebGL 的複雜度。

一款遊戲一個資料夾

三款遊戲各有一份自己的 core.js,不抽出共用的程式庫,但檔案分工一致:

inkloop/
  index.html     版面、CSS、標題與死亡畫面
  DESIGN.md      這款遊戲的規則與決策紀錄
  js/core.js     數學、亂數、localStorage、空間雜湊格、輸入
  js/world.js    模擬:實體、碰撞、規則(不碰 DOM、不畫圖)
  js/bots.js     機器人大腦:讀 world,產生 {ax, ay, act}
  js/render.js   把 world 畫到 canvas
  js/audio.js    WebAudio 合成音效
  js/game.js     狀態機、主迴圈、HUD、標題與死亡畫面

這樣每個資料夾都能單獨拿去部署。共用程式庫會讓三款遊戲綁在一起,改一款可能弄壞另一款;重複的程式碼只有幾百行,我們覺得值得。放棄的是 DRY。

固定 60 Hz 模擬,畫面跟著 requestAnimationFrame

模擬用固定時間步 1/60 秒加上累加器,畫圖則是每個 requestAnimationFrame 畫一次:

// 簡化自 game.js
const STEP = 1 / 60;
function frame(now) {
  requestAnimationFrame(frame);
  const dt = Math.min((now - last) / 1000, 0.1);
  last = now;
  if (!paused) {
    acc += dt;
    let n = 0;
    while (acc >= STEP && n < 5) {
      tick(STEP);
      acc -= STEP;
      n++;
    }
    if (n === 5) acc = 0;
  }
  Render.draw(world, cam);
}

固定步長讓碰撞和機器人的行為在不同更新率的螢幕上都一樣,120Hz 的螢幕不會讓遊戲變快。每一格最多補 5 步,分頁切回來時不會一口氣跑幾千步。

單機版可以暫停

真的 io 遊戲不能暫停,因為對手是真人。這裡的對手是機器人,切走一下就被殺只會讓人生氣,也談不上什麼公平。所以分頁隱藏或視窗失焦時會自動暫停,回來時顯示「點一下繼續」。墨圈也可以按 Esc 自己暫停。

只存三樣東西

存檔只有暱稱、個人最佳和靜音設定,放在 localStorage,key 加上遊戲名前綴(例如 inkloop.best)。每次讀寫都包在 try/catch 裡:無痕模式或封鎖儲存時,localStorage 會直接拋錯,遊戲必須照樣能玩,只是記不住設定。

效能目標

目標是一般筆電上跑 60fps,場上約 25 個角色、約 1000 個小物件。做法都很樸素:

  • 碰撞和鄰近查詢用空間雜湊格,不做兩兩比對。墨圈的筆畫線段就存在雜湊格裡,檢查閉合和切斷時只看附近的格子。
  • devicePixelRatio 最高取 2。
  • 畫面外的東西不畫。

怎麼測

  • 每款遊戲都支援 ?demo:玩家的位置也交給機器人,跳過標題直接開打。可以拿來做無頭瀏覽器測試,也能讓人直接看機器人對戰。
  • ?seed=數字 固定亂數,方便重現問題。
  • 驗收方式是用無頭 Chrome 開 ?demo 跑一段時間,抓 console 錯誤並截圖,再用真的瀏覽器實際玩過。

做的過程中又補了兩條。無頭 Chrome 的虛擬時鐘不會穩定觸發 requestAnimationFrame,所以 ?test 模式改用計時器推進模擬,畫面照常用 rAF 畫;墨圈在這個模式下每 10 秒模擬時間印出一行 JSON 數據。另外,在真的瀏覽器裡測要用本機 HTTP 伺服器(瀏覽器擴充功能開不了 file://),而且要關掉快取,不然改過的 core.js 可能讀到舊版,配上新版的 game.js 直接拋錯。

模擬也讓我們確認了一件事:io 類的經濟一定要有回收出口。墨圈的切斷原本會憑空生出墨,後來改成從被切的人身上扣;另外兩款也各自補上了回收的管道。

之後可能要回頭看的地方

  • 機器人難度:目前靠個性參數的分布控制。如果玩家普遍覺得拿第一太容易或太難,再加上依玩家表現動態調整的機制。
  • 連線版:接縫已經留好,但真的要做時,模擬層還得改成伺服器權威、搭配客戶端預測。
  • 行動裝置:已經支援觸控,但小螢幕上排行榜和小地圖會擠,可能需要可以收合的 HUD。

設計筆記