熊之島 · 技術說明

電腦對手怎麼想

沒有機器學習、沒有神經網路、沒有呼叫任何 AI 模型。它是傳統的賽局樹搜尋,用純 JavaScript 跑在玩家自己的瀏覽器裡——離線能玩、沒有金鑰、不花錢,而且一手棋通常 0.03 秒就想完,最慢也不到 0.2 秒。

平均思考時間
37ms
每手模擬盤面
≈3,600
預讀深度
2層
營運成本
NT$0

01它沒有自己的規則

做電腦對手最直覺的方式,是「再寫一套規則給它用」。但那代表規則有兩份實作:每次你改規則——像我們這幾天改了推擠、解散、萬用——都得改兩個地方,而且遲早會不同步,變成「規則改了但電腦還在用舊的」。

所以我沒有給它規則。我讓它去操作跟你一樣的介面。

你的一個回合,本質上是一串點擊:先點一張卡,再點格子、再點方向箭頭……電腦做的事,就是產生同一串點擊,跑的是完全相同的那段程式。

人類玩家 在畫面上點擊 電腦對手 產生同一串點擊 點擊 點擊 卡牌流程機 FLOWS 規則只存在這裡(唯一一份) 更新 盤面狀態 S
圖 1 兩個來源、一台機器。規則沒有第二份實作,所以改流程機的那一刻,電腦就自動改用新規則了——不需要動 AI 的程式碼一行。
實際發生過的事

有一次規則調整成「推擠時自己的熊也要跟著移動一格」。我只改了推擠那段流程的十幾行,電腦立刻就會用新規則打——我完全沒有碰 AI 那個檔案。

02只需要三個接口

要讓程式「代替手指點擊」,只需要三件事:問它現在能點哪裡、幫我點下去、以及先存檔再試(因為搜尋要試幾千種走法,不能真的改到盤面)。

src/game.html — 電腦對手區塊

// ① 現在畫面上有哪些地方可以點?
function legalActions(){
  const f = curFlow();
  if(!f) return null;              // 回合已結束
  const h = handlerOf(f), out = [];
  if(h.spaceTargets) for(const i of h.spaceTargets(f)) out.push({k:'space', v:i});
  if(h.cardTargets)  for(const u of h.cardTargets(f))  out.push({k:'card',  v:u});
  if(h.edgeArrows)   for(const e of h.edgeArrows(f))   out.push({k:'edge',  v:e.id});
  return out;
}

// ② 幫我點下去(跟人類點擊呼叫的是同一個函式)
function applyAction(a){
  const f = curFlow(); if(!f) return;
  const h = handlerOf(f);
  if(a.k==='space')     handleResult(f, h.onSpace(f, a.v));
  else if(a.k==='card') handleResult(f, h.onCard(f, a.v));
  else                  handleResult(f, h.onEdge(f, a.v));
}

// ③ 存檔 → 試 → 完整還原
function withSim(fn){
  const saveS = cloneS(), saveFlow = flowStack, saveSim = SIM;
  SIM = true; flowStack = [];
  try { return fn(); }
  finally { restoreS(saveS); flowStack = saveFlow; SIM = saveSim; }
}

第三點原本是用 JSON.stringify 存檔、JSON.parse 還原,寫起來最短。但搜尋一個回合要存還原上萬次,序列化就成了瓶頸,後來改成手工的淺層複製(cloneS/restoreS):卡牌物件不會被改,陣列 slice 就夠了。

那個 SIM 旗標是小訣竅:畫面繪製、紀錄欄、悔棋快照這三個函式開頭都有一行「模擬中就直接跳過」。所以電腦在腦中試了幾千種走法時,畫面不會閃、紀錄欄不會被灌爆、你的「取消」與「重來」也不受影響。

03一個回合,就是一串點擊

把回合看成點擊序列之後,事情變得很單純:從打出卡片開始,反覆「問可以點哪裡 → 選一個點下去」,直到流程結束。連打和萬用會疊出子流程,序列自然就變長、變巢狀,不需要特別處理。

深度 0 深度 1 深度 2 簡單的一手:直走 打出卡 選來源 選目的地 3 次點擊 → 回合結束 巢狀的一手:連打 ×2 打出連打 發動第一張+選目標 發動第二張+選目標 7 次點擊 → 回合結束
圖 2 線的高度是流程堆疊深度。連打把子流程疊上去(深度 2),子流程結束才落回主流程;序列走回深度 0 就代表這個回合完成了——這正是搜尋判斷「一手走完了」的依據。

04搜尋:想我這手,也想你的回應

知道怎麼列舉點擊序列之後,選一手棋就是四個步驟。第三步是關鍵——不只看自己這手好不好,還要看會不會送對手一個致勝機會。

現在 盤面 候選 候選 窮舉我的走法 最多 4,000 種 取前 30 名 依盤面評分 每個候選各跑一次 最佳 窮舉對手回應 最多 250 種 最終分 我的分數 − 對手最佳分
圖 3 兩層搜尋。左半是「我能怎麼走」,右半是「你會怎麼回」。減法讓那些「自己看起來很賺、但會讓對手下一手獲勝」的走法自動被淘汰——不需要另外寫防守邏輯。右半還有一道時間閘門:候選已依評分排序,算超過 1.8 秒就收手,用已經算完的候選裡挑。

src/game.html — chooseMove()

function chooseMove(me){
  return withSim(() => {
    // ① 窮舉我這回合所有可行的完整走法
    const seqs = enumerateTurns(me, AI.SEQ_TOTAL);
    if(!seqs.length) return null;

    // ② 每種走法模擬到底,替結果盤面打分
    const scored = seqs.map(seq => ({
      seq,
      score: withSim(() => { replay(seq); return evalBoard(me); })
             + Math.random()*0.5,   // 微擾:避免每局下一模一樣
    }));
    scored.sort((a,b) => b.score - a.score);

    // ③ 取前 30 名,各自模擬對手的所有回應
    const top = scored.slice(0, AI.CANDIDATES);
    const deadline = performance.now() + AI.TIME_MS;
    for(const cand of top){
      // 時間到就停:候選已依啟發值排序,先算的本來就是最有希望的幾手
      if(performance.now() > deadline && cand.final === undefined
         && top.some(c => c.final !== undefined)) break;
      cand.final = cand.score - withSim(() => {
        replay(cand.seq);
        if(S.winner) return S.winner===me ? -100000 : 100000;  // 已分勝負,對手無回應
        const you = S.turn;
        const replies = enumerateTurns(you, AI.REPLY_CAP);
        let best = replies.length ? -Infinity : 0;
        for(const r of replies){
          const v = withSim(() => { replay(r); return evalBoard(you); });
          if(v > best) best = v;
          if(best >= 100000) break;   // 對手已有一步致勝,不必再算
        }
        return best;
      });
    }

    // ④ 只在有算完對手回應的候選中,挑最終分最高的那手
    const judged = top.filter(c => c.final !== undefined);
    judged.sort((a,b) => b.final - a.final);
    return (judged[0] || top[0]).seq;
  });
}

05它在乎什麼:評分函數

搜尋只是把可能性攤開,真正決定它「棋風」的是這段打分。權重最重的那一項,是整套設計的核心判斷:

src/game.html — evalBoard()

return -8*youTop + 3*myTop + 2*(myBears - youBears) + stackScore;
項目權重為什麼
對手佔頂的格子數−8勝利條件是「所有有熊的格子最上層同色」,所以這個數字就等於距離獲勝還差幾步,是最直接的指標
自己佔頂的格子數+3控制權本身有價值,但不該蓋過上一項
雙方場上熊數差+2材料優勢;權重刻意壓低,免得它捨不得用驅逐這類換子牌
疊高加權±0.5/隻壓在高疊頂端價值更高,被壓在下面則相反
勝/負/和±100000
−20000
和局給負分,避免它選擇同歸於盡
它自己想出來的下法

測試「一步致勝」時,我以為它會用驅逐清掉場上最後一隻白熊。結果它選了飛躍——把自己的黑熊疊到那隻白熊頭上。同樣立刻獲勝,但保住了自己的熊。這種下法我沒有教它,是上面那五行權重自己推出來的。

06它不會偷看你的牌

這點在設計上是刻意的:評分函數只讀盤面,完全不碰手牌內容與牌庫順序。所以就算模擬時它「翻」到了牌庫,那些資訊也影響不了任何決策。

預測你的回應時,它用的是你面前那 3 張明牌——而那在實體遊戲裡本來就是公開資訊,人類玩家一樣看得到。換句話說,它掌握的資訊和一個認真的對手完全相同,不多也不少。

07怎麼確認它真的會下棋

「感覺還行」不算驗證。我寫了一段自動檢查:跑一局真實牌局,每個回合先窮舉出所有走法,標記出哪些是「安全的」(走完之後對手沒有一步致勝),然後看電腦選的那手在不在安全集合裡。

這種「性質檢查」比逐一寫測資更適合遊戲 AI:我不需要事先知道正確答案是什麼,只要能描述「一個好的選擇該滿足什麼性質」,就能讓程式自己去抓漏。

BGA 版沒有電腦對手

BGA 的官方文件對中離接手的 zombie 模式寫得很白:「The idea is NOT to develop an artificial intelligence for the game」。所以熊之島在 BGA 上是純雙人對戰,有人中離時接手的程式只是隨機把流程走完。這 203 行只活在網頁版。

08限制,以及可以調的旋鈕

誠實說它的天花板在哪:

要改強度不用動邏輯,改這一行的數字就好:

src/game.html

const AI = {SEQ_PER_CARD:600, SEQ_TOTAL:4000, CANDIDATES:30, REPLY_CAP:250,
            TIME_MS:1800,      // 算對手回應的時間上限
            CARD_MS:1100, STEP_MS:700, STEP_MIN:340, PLAY_BUDGET:2500, WIN_PAUSE:2600};
                               // ↑ 這一排只管播放節奏,跟棋力無關
旋鈕調高的效果
CANDIDATES更少漏防(10→30 就是這樣修好的),但思考變慢
SEQ_TOTAL更不容易錯過冷門好棋
REPLY_CAP對你的回應預測得更準
TIME_MS複雜盤面願意多想一下,代價是偶爾要等它
STEP_MS播放每步的間隔,只影響觀看節奏

想讓它變弱(做「簡單」難度),最省事的方式是把 CANDIDATES 降到 1~3,或直接從所有走法裡隨機挑——它就會變成一個只看眼前的對手。