AIゲームの作り方 約10分で読めます

オセロAIの作り方|HTML1枚で遊べるコードと、読む深さ・採点で強さがどう変わるか

「オセロAIを作る」の文字と、背景に映るオセロの盤面

オセロのAIは、先の手を何手か読んで、盤面に点をつけ、いちばん点の高い手を選ぶ形で作れます。この記事のコードは、HTML1枚で遊べるオセロで、αβ法で読む手を絞り、場所ごとの重みで盤面を採点します。人が黒、AIが白です。

コードはNodeで関数をそのまま呼んで測りました。読む深さごとの調べる局面の数、でたらめな相手との対戦、深さや評価のしかたを変えたAI同士の対戦を、すべて実際に数えた結果です。

この記事で分かること

  • オセロAIのコード全体(合法手・αβ法の探索・場所の重みの採点)
  • 深さ1〜6で、調べる局面がどう増えるか(実測)
  • 深く読むAIが、浅いAIにどれだけ勝つか(100局ずつ)
  • 採点を「場所の重み」にした効果(石の数だけで採点するAIとの対戦)
  • 確かめられていないこと

まるばつのように、盤面が小さいゲームのAIは、AIと対戦するゲームの作り方で扱っています。オセロは盤面が大きく、すべての手を終局まで読めないので、先を読む深さを区切ります。

この記事の目次
  1. 01オセロAIが考える3つの部品
  2. 02コード全体
  3. 03先を読む深さと、調べる局面の数
  4. 04深く読むと、強くなるか
    1. でたらめな相手には、深さの差が出なかった
    2. AI同士では、深い側が勝った
    3. 場所の重みの効果
  5. 05確かめられていないこと
  6. 06うまくいかないとき
  7. 07よくある疑問
  8. 08この記事のまとめ

オセロAIが考える3つの部品

オセロAIの3つの部品の図。1は合法手を出す。8方向に相手の石を挟めるかを調べ、盤の端を越えないようにする。2は先を読む。自分と相手が交互に打つ先を決めた深さまで読み、αβ法で無駄な枝を省く。3は盤面に点をつける。場所ごとの重みで石の位置を採点する。角は100点、角の隣は-20から-50点
この3つを組み合わせます

コード全体

次のコードを、index.htmlという名前で保存してブラウザで開くと、そのまま遊べます。サーバーは要りません。黒の置ける場所には、小さな点が出ます。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>オセロ(AIと対戦)</title>
<style>
body{font:17px/1.6 sans-serif;text-align:center}
#board{display:grid;grid-template-columns:repeat(8,44px);gap:2px;justify-content:center;background:#063;padding:4px;width:fit-content;margin:12px auto}
#board button{width:44px;height:44px;border:0;background:#2a7;font-size:28px;line-height:1;padding:0}
</style>
</head>
<body>
<h1>オセロ</h1>
<p>あなたは黒(●)、AIは白(○)です。</p>
<div id="board"></div>
<p id="msg"></p>
<script>
// 盤面は64個の配列。1=黒、-1=白、0=空き
const DIRS = [-9, -8, -7, -1, 1, 7, 8, 9];
const WEIGHT = [ // 角は取りたい(高い点)。角の隣は取りたくない(低い点)
  100, -20, 10, 5, 5, 10, -20, 100,
  -20, -50, -2, -2, -2, -2, -50, -20,
  10, -2, 1, 1, 1, 1, -2, 10,
  5, -2, 1, 0, 0, 1, -2, 5,
  5, -2, 1, 0, 0, 1, -2, 5,
  10, -2, 1, 1, 1, 1, -2, 10,
  -20, -50, -2, -2, -2, -2, -50, -20,
  100, -20, 10, 5, 5, 10, -20, 100];

function newBoard() { const b = Array(64).fill(0); b[27] = b[36] = -1; b[28] = b[35] = 1; return b; }

// その手で裏返せる石の位置を返す(置けないときは空の配列)
function flips(b, pos, me) {
  if (b[pos] !== 0) return [];
  const x = pos % 8, out = [];
  for (const d of DIRS) {
    const line = [];
    let p = pos + d, px = x;
    for (;;) {
      const nx = p % 8;
      if (p < 0 || p > 63 || Math.abs(nx - px) > 1) break; // 盤の端を越えない
      if (b[p] === -me) { line.push(p); px = nx; p += d; continue; }
      if (b[p] === me && line.length) out.push(...line);
      break;
    }
  }
  return out;
}
function moves(b, me) { const m = []; for (let i = 0; i < 64; i++) if (flips(b, i, me).length) m.push(i); return m; }
function play(b, pos, me) { const n = b.slice(); n[pos] = me; for (const f of flips(b, pos, me)) n[f] = me; return n; }

// 評価: 場所の重みの合計(石の数だけで評価する版も比べるため、切り替えられる)
function evaluate(b, me, mode) {
  let s = 0;
  for (let i = 0; i < 64; i++) s += b[i] * (mode === 'count' ? 1 : WEIGHT[i]);
  return s * me;
}

let nodes = 0;
// αβ法つきの探索。depth手先まで読んで、meにとっての評価を返す
function search(b, me, depth, alpha, beta, mode) {
  nodes++;
  const mv = moves(b, me);
  if (depth === 0) return evaluate(b, me, mode);
  if (!mv.length) {
    if (!moves(b, -me).length) return evaluate(b, me, mode) * 100; // 両者とも置けない=終局
    return -search(b, -me, depth - 1, -beta, -alpha, mode);        // パス
  }
  for (const p of mv) {
    const s = -search(play(b, p, me), -me, depth - 1, -beta, -alpha, mode);
    if (s > alpha) alpha = s;
    if (alpha >= beta) break;
  }
  return alpha;
}

function bestMove(b, me, depth, mode = 'weight') {
  let best = -Infinity, pick = null;
  for (const p of moves(b, me)) {
    const s = -search(play(b, p, me), -me, depth - 1, -Infinity, Infinity, mode);
    if (s > best) { best = s; pick = p; }
  }
  return pick;
}

if (typeof document !== 'undefined') {
  let board = newBoard();
  const el = document.getElementById('board'), msg = document.getElementById('msg');
  function draw() {
    el.innerHTML = '';
    const legal = new Set(moves(board, 1));
    board.forEach((v, i) => {
      const b = document.createElement('button');
      b.textContent = v === 1 ? '●' : v === -1 ? '○' : legal.has(i) ? '·' : '';
      b.style.color = v === 1 ? '#000' : '#fff';
      b.onclick = () => humanMove(i);
      el.appendChild(b);
    });
    const c = board.filter((v) => v === 1).length, w = board.filter((v) => v === -1).length;
    msg.textContent = `黒 ${c} - 白 ${w}`;
  }
  function aiTurn() {
    while (true) {
      const m = moves(board, -1);
      if (!m.length) break;
      board = play(board, bestMove(board, -1, 4), -1);
      if (moves(board, 1).length) break; // 人が置けるなら交代
    }
    draw();
  }
  function humanMove(i) {
    if (!flips(board, i, 1).length) return;
    board = play(board, i, 1);
    setTimeout(aiTurn, 50);
    draw();
  }
  draw();
}
</script>
</body>
</html>
コードを動かした画面。黒の人が1手打ち、白のAIが1手返した局面で、黒が置ける場所に小さな点が出ている。AIは深さ4まで読んで1手を決める。60手で石が64個になり、終局まで動くことを確認した
画面は、画面を出さないChromeで撮りました

画面は、人が黒で1手打ち、白のAIが1手返した直後です。動作の確認として、初期配置からの黒の合法手が4つ(19・26・37・44)になること、60手で盤面が64個の石で埋まることも確かめました。

先を読む深さと、調べる局面の数

探索は、αβ法で枝を省くので、読む深さを1つ増やしたときの局面の増え方が、実際にどれくらいかを測りました。最初の局面と、でたらめに20手進めた中盤の局面で、1手を決めるまでに調べた局面を数えています。

読む深さと調べる局面の数の図。中盤の局面で、深さ1は16局面、深さ2は290、深さ3は1731、深さ4は11436で54ミリ秒、深さ5は49445で218ミリ秒、深さ6は395059で1542ミリ秒。最初の局面は、深さ6でも2685局面で14ミリ秒
最初の局面は、手の選択肢が少ないので、増え方が小さくなります

中盤では、深さ4で11,436局面(約54ミリ秒)、深さ6で395,059局面(約1.5秒)でした。最初の局面は、置ける場所が少ないので、深さ6でも2,685局面(約14ミリ秒)です。中盤の1.5秒は、1手ごとなので、深さ6では、遊んでいて待たされる長さになります。この記事のコードは、深さ4(約54ミリ秒)にしています。

深く読むと、強くなるか

AI同士や、でたらめに打つ相手と、100局ずつ戦わせました。AIは、黒と白を半分ずつ受け持ちます。AI同士の対戦では、最初の6手をでたらめにして、同じ対局が繰り返されないようにしました。

深さと評価のしかたを変えた対戦の結果。深さ1対でたらめな相手は、86勝3分11敗。深さ4対でたらめな相手は、87勝4分9敗。深さ2対深さ1は83勝2分15敗。深さ4対深さ1は95勝2分3敗。深さ4対深さ2は82勝0分18敗。場所の重み版対石の数だけ版は、深さ3で78勝4分18敗
深さが違うAI同士では、深い側が勝ち越しました

でたらめな相手には、深さの差が出なかった

でたらめな相手との対戦は、深さ1で86勝3分11敗、深さ4で87勝4分9敗でした。深さ2と3も、82〜83勝です。読む深さを増やしても、でたらめな相手に対しては、勝率がほとんど変わりませんでした。理由は確かめていません。

AI同士では、深い側が勝った

深さが違うAI同士では、深い側が勝ち越しました。深さ2対深さ1は83勝2分15敗、深さ4対深さ1は95勝2分3敗、深さ4対深さ2は82勝0分18敗でした。でたらめな相手では見えなかった強さの差が、AI同士の対戦では出ています。

場所の重みの効果

同じ深さ3で、盤面の採点だけを変えました。場所の重みで採点するAIと、石の数だけで採点するAIが100局戦って、場所の重み版は78勝4分18敗でした。角を取りたい、角の隣は取りたくない、という場所の重みが、石の数だけの採点より大きく効いています。

確かめられていないこと

  • 人が遊んだときの強さと面白さ勝敗は、AIとでたらめな相手、AI同士の対戦で測っています。人との対戦は、確かめていません。
  • でたらめな相手に深さの差が出なかった理由
  • 終盤の読み切り(残りの手が少ないとき、終局まで読む方法)
  • 場所の重みの数字の最適化(この記事の表は、1つの例です)
  • スマートフォンでの表示と操作(盤面は幅44pxのマスで作っています)
  • 深さ6以上の対戦の強さ(時間がかかるため、調べる局面の数だけ測りました)

確かめていないものは、動かして確かめてから、別の記事で扱います。

うまくいかないとき

症状 確認すること
置けない場所に置けてしまう 合法手の判定(flips)で、盤の端を越えて数えていないかを確かめます。左右の端では、横の位置が2つ以上飛ぶと、別の行へ回り込みます
AIの考えが遅い 読む深さ(bestMoveの第3引数)を下げます。この記事の測定では、中盤の深さ6で約1.5秒でした
パスができない 置ける場所がなくなったとき、相手に手番を渡す処理があるかを確かめます。コードでは、aiTurnのwhileがそれを担当します
AIが角の隣ばかり打つ 場所の重みの符号を確かめます。角は大きなプラス、角の隣はマイナスです

関連記事AIと対戦するゲームの作り方|負けないまるばつAI(ミニマックス法)と弱くする方法

よくある疑問

AIの強さを調整するには、どうしますか

読む深さを変えるのが簡単です。深さ1と深さ4では、AI同士で95勝2分3敗の差がありました。人が遊んだときの感じ方は、確かめていません。

ChatGPTのようなAIに、オセロを打たせることはできますか

この記事では扱っていません。この記事のAIは、読む深さと点数で手を選ぶ仕組みです。

将棋や囲碁にも使えますか

考え方は同じですが、手の数が非常に多いので、この記事のコードのままでは動かせません。確かめていません。

この記事のまとめ

この記事のまとめ

  • オセロAIは、合法手・先読み(αβ法)・場所の重みの採点で作れる
  • 調べる局面は、中盤の深さ4で約1.1万、深さ6で約39.5万。深さ4は約54ミリ秒だった
  • 深さの違うAI同士では、深い側が勝ち越した(深さ4対深さ1で95勝2分3敗)
  • 場所の重みの採点は、石の数だけの採点に、100局で78勝4分18敗だった
  • 人が遊んだときの強さと、でたらめな相手に差が出なかった理由は、確かめていない