マガジン

30分で作る二人対戦の三目並べ:同じ端末で交互に置く

遊びをつくる

執筆にClaudeを使用しています。

増えすぎた部品を脇へよけ、一周できる小さなゲームのコースを完成させる手元。
記事のテーマをもとに制作したAI生成の写真風イメージ。

二人で遊ぶゲームを作るとき、最初にぶつかるのは「いま誰の番か」をどこに持たせるかです。ネットワークも相手役のAIも要らない同じ端末の交互対戦なら、答えは変数1つで済みます。三目並べは、手番の切り替えと勝敗判定という二人対戦の骨格だけを、最小の量で作れる題材です。 この記事では、HTMLファイル1つで動く三目並べを、番号付きの手順で作ります。30分は目安で、コードを貼る、読む、動作を確かめる、の3つに分けて使います。完成物は外部ライブラリもサーバーも使わないので、そのままアソバースへの投稿にも回せます。 作る前に、確かめることを3つに絞ります。

この記事の目次
  1. 先に決めること:盤面は9個の配列、手番は1つの変数
  2. 手順1:ファイルを作ってコードを貼る
  3. 手順2:手番の切り替えと勝敗判定を読む
  4. 手順3:3つの盤面で動作を確かめる
  5. つまずきやすい点
  6. 次の行動

先に決めること:盤面は9個の配列、手番は1つの変数

コードを書く前に、データの形を決めます。ここで迷うと後の判定が複雑になります。

盤面は、長さ9の配列1本で持ちます。マスの番号は左上を0として右へ数え、段が変わるたびに続けて振ります。上段が0・1・2、中段が3・4・5、下段が6・7・8です。空のマスは空文字、置かれたマスは「○」か「×」の文字を入れます。二次元配列にしないのは、判定でマスを指すとき番号1つで済み、ボタンの描画にも使いやすいからです。

手番は、変数turnに「○」か「×」を入れて持ちます。「誰の番か」を保存する場所はここだけにします。画面の表示も判定もこの変数と配列から導けるようにしておくと、表示と中身がずれる不具合が起きません。

勝ちのパターンは、マス番号3つ組を8本、あらかじめ並べておきます。

  • 横:0-1-2、3-4-5、6-7-8
  • 縦:0-3-6、1-4-7、2-5-8
  • 斜め:0-4-8、2-4-6

判定は「この8本のうち、3マスが同じ記号でそろっているものがあるか」を調べるだけです。

手順1:ファイルを作ってコードを貼る

  1. 好きなフォルダにindex.htmlという名前の空のテキストファイルを作ります。後で投稿する場合に入口になる名前なので、最初からこの名前にします。
  2. 下のコードをそのまま貼り付けて保存します。
  3. ファイルをブラウザで開きます。盤面と「○ の番です」が出れば成功です。
html
<!doctype html>
<meta charset="utf-8">
<title>三目並べ</title>
<style>
  #board{display:grid;grid-template-columns:repeat(3,80px);gap:4px}
  button.cell{width:80px;height:80px;font-size:40px}
</style>
<p id="msg"></p>
<div id="board"></div>
<button id="reset">もう一度</button>
<script>
const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
let cells, turn, over;
const boardEl = document.getElementById("board");
const msg = document.getElementById("msg");

function winner() {
  for (const [a,b,c] of LINES) {
    if (cells[a] && cells[a] === cells[b] && cells[a] === cells[c]) return cells[a];
  }
  return null;
}
function render() {
  boardEl.innerHTML = "";
  cells.forEach((v, i) => {
    const b = document.createElement("button");
    b.className = "cell";
    b.textContent = v;
    b.disabled = over || v !== "";
    b.onclick = () => play(i);
    boardEl.appendChild(b);
  });
}
function play(i) {
  if (over || cells[i]) return;
  cells[i] = turn;
  const w = winner();
  if (w) { over = true; msg.textContent = w + " の勝ちです"; }
  else if (cells.every(v => v)) { over = true; msg.textContent = "引き分けです"; }
  else { turn = turn === "○" ? "×" : "○"; msg.textContent = turn + " の番です"; }
  render();
}
function start() {
  cells = Array(9).fill(""); turn = "○"; over = false;
  msg.textContent = turn + " の番です";
  render();
}
document.getElementById("reset").onclick = start;
start();
</script>

ここまでで10分程度を見込みます。動かないときは、貼り付けの途中で<script>や</script>が欠けていないか確かめてください。

手順2:手番の切り替えと勝敗判定を読む

動いたら、残りの時間で中身を読みます。核になるのはplay関数の中の順序です。

置く処理は「確認→書き込み→判定→切り替え」の順

play(i)は次の順で進みます。

  1. すでに終わっているか、そのマスが埋まっていたら何もせず戻ります。ボタンを無効にしてあっても、この確認は残します。画面の状態に頼らず、関数自体が不正な操作を弾く形にしておくと、後で操作方法を増やしたときに安全です。
  2. cells[i] = turnで、いまの手番の記号を書き込みます。
  3. 勝ちを判定します。
  4. 勝ちでも引き分けでもないときだけ、turnを入れ替えます。

3と4の順序が肝心です。手番を先に切り替えてしまうと、勝った直後の判定で「相手の記号」を見ることになり、表示が逆になります。書き込み、判定、切り替えの順を崩さないでください。

勝敗判定は8本を順に調べるだけ

winner関数は、8本の3つ組を順に取り出し、3マスが同じ記号かを調べます。条件の最初にcells[a]と書いてあるのは、空文字のマス同士が「同じ」と見なされるのを防ぐためです。空文字は偽として扱われるので、空の3マスが勝ちと誤判定されることはありません。

勝ちを引き分けより先に調べる

playの中では、勝ちを先に、引き分け(全マス埋まり)を後に調べています。9手目で最後のマスが埋まったとき、同時に3つそろう場合があるからです。引き分けを先に調べると、その勝ちが取り消されてしまいます。この順序は次の手順で実際に確かめます。

手順3:3つの盤面で動作を確かめる

判定は、盤面の例を決めて手でなぞると確実に確認できます。以下はマス番号を置く順に並べたものです。左から順に○、×、○、×…と交互に押します。

確認A:横一列で勝つ

0、3、1、4、2の順に押します。○が0・1・2(上段)、×が3・4(中段の2つ)を取った状態で、5手目の○で上段がそろいます。「○ の勝ちです」が出て、残りのマスが押せなくなれば正常です。

確認B:引き分けになる

0、1、2、4、3、5、7、6、8の順に押します。最終的な盤面は次のとおりです。

  • 上段:○、×、○
  • 中段:○、×、×
  • 下段:×、○、○

縦・横・斜めのどこにも同じ記号が3つそろっていないので、9手目で「引き分けです」が出ます。途中で勝ちが出てしまう場合は、押す順番か判定の3つ組の数字を見直してください。

確認C:最後の1手で勝つ

0、1、2、3、7、5、4、6、8の順に押します。最終的な盤面は次のとおりです。

  • 上段:○、×、○
  • 中段:×、○、×
  • 下段:×、○、○

○が0・4・8の斜めをそろえ、同時に9マスすべてが埋まります。ここで出るのが「引き分けです」ではなく「○ の勝ちです」であれば、判定の順序は正しい状態です。

さらに理解を深めたいなら、playの中のif (w)の行とelse if (cells.every...)の行を入れ替えて、確認Cをやり直してみてください。結果がどう変わるかを自分の画面で見ると、判定順の意味が腹に落ちます。確認後は元に戻します。

つまずきやすい点

  • 勝ったあとも置けてしまう:overの確認が抜けています。playの最初の行と、ボタンのdisabledの両方を見直します。
  • 同じマスを上書きできる:cells[i]が空かどうかの確認が抜けています。
  • ○のまま交代しない:turnの入れ替えがelseの中にあるか、render()より前に実行されているかを確かめます。
  • リセット後に前の記号が残る:startでcellsを新しい配列に作り直しているか確かめます。配列を使い回して一部だけ消す書き方は、消し忘れの原因になります。
  • 判定の3つ組の写し間違い:8本のうち1本の数字を誤ると、特定のそろい方だけ反応しません。確認Aのような横列だけでなく、縦と斜めも1回ずつ手で押して確かめてください。

次の行動

まず上の3つの盤面を自分の手でなぞり、すべて期待どおりの文言が出ることを確認してください。そのうえで、勝った列に色を付ける、勝敗の通算を数えるなど、1つだけ機能を足してみてください。

作ったファイルは、アソバースの投稿画面にHTMLとして直接アップロードできます。入口のindex.htmlは、ZIPで投稿する場合でも直下に置きます。投稿は「入力→表示の確認→公開」の3段階で、下書き保存もできます。公開にはメールアドレスの確認が必要です。

なお、この記事の作り方は「同じ端末で交互に置く」形です。離れた相手との対戦は、投稿作品向けの共通対戦APIが未提供のため、この方法では作れません。同じ画面でのふたり対局と招待リンクのオンライン対局の両方を持つ公式作品「灯りのチェス」を開いて、手番表示や終局の見せ方を見比べると、次の改良の手がかりになります。