沒有機器學習、沒有神經網路、沒有呼叫任何 AI 模型。它是傳統的賽局樹搜尋,用純 JavaScript 跑在玩家自己的瀏覽器裡——離線能玩、沒有金鑰、不花錢,而且一手棋通常 0.03 秒就想完,最慢也不到 0.2 秒。
做電腦對手最直覺的方式,是「再寫一套規則給它用」。但那代表規則有兩份實作:每次你改規則——像我們這幾天改了推擠、解散、萬用——都得改兩個地方,而且遲早會不同步,變成「規則改了但電腦還在用舊的」。
所以我沒有給它規則。我讓它去操作跟你一樣的介面。
你的一個回合,本質上是一串點擊:先點一張卡,再點格子、再點方向箭頭……電腦做的事,就是產生同一串點擊,跑的是完全相同的那段程式。
有一次規則調整成「推擠時自己的熊也要跟著移動一格」。我只改了推擠那段流程的十幾行,電腦立刻就會用新規則打——我完全沒有碰 AI 那個檔案。
要讓程式「代替手指點擊」,只需要三件事:問它現在能點哪裡、幫我點下去、以及先存檔再試(因為搜尋要試幾千種走法,不能真的改到盤面)。
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 旗標是小訣竅:畫面繪製、紀錄欄、悔棋快照這三個函式開頭都有一行「模擬中就直接跳過」。所以電腦在腦中試了幾千種走法時,畫面不會閃、紀錄欄不會被灌爆、你的「取消」與「重來」也不受影響。
把回合看成點擊序列之後,事情變得很單純:從打出卡片開始,反覆「問可以點哪裡 → 選一個點下去」,直到流程結束。連打和萬用會疊出子流程,序列自然就變長、變巢狀,不需要特別處理。
知道怎麼列舉點擊序列之後,選一手棋就是四個步驟。第三步是關鍵——不只看自己這手好不好,還要看會不會送對手一個致勝機會。
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;
});
}
搜尋只是把可能性攤開,真正決定它「棋風」的是這段打分。權重最重的那一項,是整套設計的核心判斷:
src/game.html — evalBoard()
return -8*youTop + 3*myTop + 2*(myBears - youBears) + stackScore;
| 項目 | 權重 | 為什麼 |
|---|---|---|
| 對手佔頂的格子數 | −8 | 勝利條件是「所有有熊的格子最上層同色」,所以這個數字就等於距離獲勝還差幾步,是最直接的指標 |
| 自己佔頂的格子數 | +3 | 控制權本身有價值,但不該蓋過上一項 |
| 雙方場上熊數差 | +2 | 材料優勢;權重刻意壓低,免得它捨不得用驅逐這類換子牌 |
| 疊高加權 | ±0.5/隻 | 壓在高疊頂端價值更高,被壓在下面則相反 |
| 勝/負/和 | ±100000 −20000 | 和局給負分,避免它選擇同歸於盡 |
測試「一步致勝」時,我以為它會用驅逐清掉場上最後一隻白熊。結果它選了飛躍——把自己的黑熊疊到那隻白熊頭上。同樣立刻獲勝,但保住了自己的熊。這種下法我沒有教它,是上面那五行權重自己推出來的。
這點在設計上是刻意的:評分函數只讀盤面,完全不碰手牌內容與牌庫順序。所以就算模擬時它「翻」到了牌庫,那些資訊也影響不了任何決策。
預測你的回應時,它用的是你面前那 3 張明牌——而那在實體遊戲裡本來就是公開資訊,人類玩家一樣看得到。換句話說,它掌握的資訊和一個認真的對手完全相同,不多也不少。
「感覺還行」不算驗證。我寫了一段自動檢查:跑一局真實牌局,每個回合先窮舉出所有走法,標記出哪些是「安全的」(走完之後對手沒有一步致勝),然後看電腦選的那手在不在安全集合裡。
這種「性質檢查」比逐一寫測資更適合遊戲 AI:我不需要事先知道正確答案是什麼,只要能描述「一個好的選擇該滿足什麼性質」,就能讓程式自己去抓漏。
BGA 的官方文件對中離接手的 zombie 模式寫得很白:「The idea is NOT to develop an artificial intelligence for the game」。所以熊之島在 BGA 上是純雙人對戰,有人中離時接手的程式只是隨機把流程走完。這 203 行只活在網頁版。
誠實說它的天花板在哪:
要改強度不用動邏輯,改這一行的數字就好:
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,或直接從所有走法裡隨機挑——它就會變成一個只看眼前的對手。