マガジン

格子点を結んで正方形を作るゲーム:傾いた正方形は見えるか

遊びをつくる

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

チェスのナイトから枝分かれした影が盤面へ伸び、先読みの候補を表す版画
記事のテーマをもとに制作したAI生成イラスト。

5×5の格子点が並ぶ盤面で、赤と青が交互に点を取り、先に同じ色の4点で正方形を作った方が勝ち、というゲームを考えてみます。軸に平行な小さい正方形は、盤面を見ればすぐ分かります。では、座標で (0,2)、(1,0)、(3,1)、(2,3) の4点が同じ色になったらどうでしょうか。この4点は、傾いた正方形の頂点です。 この傾いた正方形が、プレイヤーにどれだけ見えるかが、このゲームの面白さを左右します。見えなければ「軸平行の正方形を塞ぎ合うだけ」のゲームになります。見えすぎれば、発見の驚きがなくなります。 この記事では、正方形の性質を判定コードに落とすところから始めます。そのうえで、盤面の大きさと補助表示を変えたときに、決め手の正方形がどう変わるかを調べる実験の設計書を示します。結果は書きません。手元で試して確かめるための手順と、結果の読み方をまとめます。

この記事の目次
  1. 数学の核:辺のベクトルを90度回す
  2. 準備:最小のプロトタイプ
  3. 手順と記録:条件を変えて遊ぶ
  4. 結果の読み方
  5. 起こりやすい誤り
  6. 次の行動

数学の核:辺のベクトルを90度回す

正方形の判定に、角度や距離の計算は要りません。点Pから点Qへ向かう辺を、横にいくつ・縦にいくつ進むかの組 (dx, dy) で表します。これが辺のベクトルです。このベクトルを90度回すと (−dy, dx) になります。Pと Q のそれぞれにこの回したベクトルを足した2点が、正方形の残り2頂点です。

冒頭の例で確かめます。(0,2) から (1,0) へ進む辺は (1,−2) です。これを回すと (2,1) になります。(1,0) に (2,1) を足すと (3,1)、(0,2) に足すと (2,3) です。確かに冒頭の4点になります。傾いた正方形も、軸平行の正方形も、同じ式で扱えます。

画面の座標は下向きがプラスなので、回転の向きが見た目と逆になることがあります。そこで判定では、(−dy, dx) と (dy, −dx) の両方を試します。こうすれば、座標系の向きを気にする必要がなくなります。

盤面に正方形はいくつあるか

盤面の1辺に点がn個あるとき、正方形の総数は、辺の長さを基準にした次の考え方で数えられます。点を結んで正方形の4頂点がちょうど囲まれる、軸平行な外枠を考えます。外枠の一辺をk(1 ≦ k ≦ n−1)とすると、外枠はn−k個ずつ縦横に置けるので (n−k)² 個あります。そして各外枠の中には、頂点が外枠の各辺上にある正方形がちょうどk個入ります。軸平行のものもその1個に含まれます。総数は k×(n−k)² を k=1 から n−1 まで足したものです。

軸平行の正方形だけを数えると (n−k)² の合計になります。総数との差が、傾いた正方形の数です。

  • 3、軸平行:5、傾き:1、合計:6
  • 4、軸平行:14、傾き:6、合計:20
  • 5、軸平行:30、傾き:20、合計:50
  • 6、軸平行:55、傾き:50、合計:105
  • 7、軸平行:91、傾き:105、合計:196

盤面が大きくなるほど、傾いた正方形の方が数で多くなります。1辺7点の盤面では、傾いた正方形が軸平行の正方形より多くなります。これが「幾何の性質を遊びにする」ときの種になります。傾きは例外ではなく、盤面のほとんどを占める主役になります。

準備:最小のプロトタイプ

道具はブラウザとテキストエディタだけです。次のコードを index.html として保存し、ブラウザで開きます。赤と青が同じ画面で交互に点をクリックする、ふたり用です。

html
<canvas id="c"></canvas><p id="m">赤から。点をクリック</p>
<script>
const N = 6, g = {}; let turn = 0;
const cv = c.getContext("2d"), key = (x, y) => x + "," + y;
c.width = c.height = 60 * N;
function squaresWith(x, y, t) { // (x,y)を含む t 色の正方形の辺ベクトルを返す
  const found = [];
  for (const k in g) {
    if (g[k] !== t) continue;
    const [px, py] = k.split(",").map(Number), dx = px - x, dy = py - y;
    if (!dx && !dy) continue;
    for (const s of [1, -1])
      if (g[key(x - s*dy, y + s*dx)] === t && g[key(px - s*dy, py + s*dx)] === t) found.push([dx, dy]);
  }
  return found;
}
function draw() {
  cv.clearRect(0, 0, 60*N, 60*N);
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
    const v = g[key(x, y)];
    cv.fillStyle = v === undefined ? "#bbb" : v ? "#26c" : "#d33";
    cv.beginPath(); cv.arc(30 + 60*x, 30 + 60*y, v === undefined ? 4 : 10, 0, 7); cv.fill();
  }
}
c.onclick = e => {
  const r = c.getBoundingClientRect();
  const x = Math.round((e.clientX - r.left - 30) / 60), y = Math.round((e.clientY - r.top - 30) / 60);
  if (x < 0 || y < 0 || x >= N || y >= N || g[key(x, y)] !== undefined) return;
  const t = turn++ % 2; g[key(x, y)] = t; draw();
  const f = squaresWith(x, y, t);
  if (f.length) { m.textContent = (t ? "青" : "赤") + "の勝ち、" + turn + "手目、辺ベクトル " + f[0]; c.onclick = null; }
};
draw();
function brute(n) { // 正方形の総数を総当たりで数える(検算用)
  let s = 0; const ok = (a, b) => a >= 0 && b >= 0 && a < n && b < n;
  for (let x = 0; x < n; x++) for (let y = 0; y < n; y++) for (let u = 0; u < n; u++) for (let v = 0; v < n; v++) {
    const dx = u - x, dy = v - y;
    if ((dx || dy) && ok(x - dy, y + dx) && ok(u - dy, v + dx)) s++;
  }
  return s / 4;
}
function randomGame() { // 乱数で交互に置き、決着の手数と傾きの有無を返す(基準線用)
  for (const k in g) delete g[k];
  const pts = [];
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) pts.push([Math.random(), x, y]);
  pts.sort((a, b) => a[0] - b[0]);
  for (let i = 0; i < pts.length; i++) {
    const [, x, y] = pts[i], t = i % 2;
    g[key(x, y)] = t;
    const f = squaresWith(x, y, t);
    if (f.length) return { moves: i + 1, tilted: f.some(([a, b]) => a && b) };
  }
  return null; // 引き分け
}
</script>

最初にやるのは検算です。開発者ツールのコンソールで brute(5) を実行し、表の50と一致するか確かめます。3〜7のすべてで一致すれば、回転の式の書き間違いはほぼ排除できます。一致しなければ、squaresWith に手を入れる前に brute の式を疑います。

randomGame() を呼ぶと盤面の状態 g が空になります。遊びの途中では呼ばず、実行後は再読み込みしてください。

手順と記録:条件を変えて遊ぶ

問いと条件

この実験で確かめる問いは次の二つです。

  1. 盤面を広げると、決め手の正方形に傾いたものが増えるか。
  2. 「あと1点で正方形になる空きマス」を光らせる補助表示は、傾いた正方形の発見に効くか。

条件は二つの軸で変えます。盤面の大きさは N=5、6、7 の3通りです。補助表示は「なし」「あり」の2通りです。N の値を書き換えるだけで盤面は変わります。

補助表示は、読者が自分で足す課題です。squaresWith を流用できます。手番の色 t で、空きマスに仮に点を置き、squaresWith の結果が空でなければ、そのマスを光らせます。判定のあとは、仮置きの点を delete g[key(x, y)] で消します。

基準線を作る

人が遊ぶ前に、乱数だけで遊ばせた場合の数字を取ります。各Nで randomGame() を数百回ずつ呼び、次の3つを集計します。

  • 決着までの平均手数
  • 決め手が傾いた正方形だった割合
  • 引き分け(戻り値が null)の割合

この数字は「何も考えずに置いたら、盤面の構造だけでこうなる」という目盛りになります。人が遊んだ結果を、この目盛りと比べます。

人が遊ぶ

ふたりで1局ずつ遊びます。局ごとに次の項目を記録します。

  • 盤面の大きさ(N)
  • 補助表示の有無
  • 総手数
  • 決め手の辺ベクトル(画面に表示される値)
  • 決め手が傾いていたか(dx と dy がどちらも0でないとき傾きです)
  • 引き分けだったか
  • 先手だった人
  • 「ここで見落とした」と感じた局面のメモ

回数は条件の組ごとに10局前後を目安にします。N が3通り、補助が2通りなので6条件、合計60局前後です。多すぎる場合は N=5 と 7 だけに絞っても構いません。

遊ぶ順序と先手は固定しません。最初の条件ばかり補助なしにそろえると、遊ぶうちに慣れた影響が補助ありの結果に混ざります。補助なしとありを交互に入れ替え、先手も毎局入れ替えます。

結果の読み方

集計は、条件ごとの「傾き割合」(傾いた決め手の局数÷決着した局数)と平均手数から始めます。比べるものは3つあります。

① 傾き割合を、基準線と、盤面上の存在比の両方と比べる

存在比は、表の傾き÷合計です。N=5 で20/50、N=6 で50/105、N=7 で105/196なので、約0.4、約0.48、約0.54です。人が遊んだ傾き割合が基準線より明らかに低ければ、軸平行の正方形ばかりが見えていて、傾きが見落とされている可能性があります。ただし、人は相手の正方形を妨害します。基準線より低いだけで「見えていない」と断定せず、メモの「見落とした局面」と合わせて判断してください。

② 補助あり・なしで傾き割合を比べる

補助ありで傾き割合が上がるなら、補助が傾きを見えるようにしたと読めます。ほとんど変わらないなら、補助の見せ方が弱いか、局数が足りないかのどちらかです。10局前後では、差が数局分なら偶然の範囲かもしれません。差が小さいときは、局数を倍にしてから判断します。

③ 平均手数と引き分けを見る

補助ありで手数が大きく縮むなら、終盤の駆け引きが薄くなっています。それが狙いかどうかは、ゲームの設計意図で決まります。引き分けが多い条件は、盤面が小さすぎるか、妨害が強すぎる可能性があります。

結果から設計を変えるときの目安は次のとおりです。傾いた正方形に気づく驚きを主役にしたいなら、補助は常時出さず、終盤の数手だけ、または自分の手番の候補だけに絞ります。気づきやすさを優先したいなら、補助を常時出し、盤面は N=5 に抑えます。

起こりやすい誤り

  • 回転の向きを片方しか試さない。 s を1つに固定すると、正方形が盤面の半分の向きで見つかりません。brute と一致しない原因の筆頭です。
  • 辺の長さ0を除外し忘れる。 同じ点同士を辺とみなすと、判定が空振りします。!dx && !dy で飛ばしています。
  • 新しく置いた点を含まない正方形を探す。 勝敗の判定は、置いた点を含む正方形だけで足ります。置く前に存在しなかったものだけを探せばよいので、全探索は不要です。
  • クリック位置がずれる。 getBoundingClientRect は表示上のサイズを基準にします。CSSで canvas を拡大・縮小すると、座標がずれます。幅は c.width と同じ値にそろえます。
  • 乱数の並べ替えに sort(() => Math.random() - 0.5) を使う。 結果に偏りが出ます。コードでは乱数をキーにして並べ替えています。
  • 引き分けを無視して平均を出す。 randomGame() は null を返すので、手数の集計では除外し、引き分けは別に数えます。
  • 先手の有利を見落とす。 先手が有利かどうかは、この記事では分かりません。毎局の先手入れ替えと、先手の記録で、条件の差とは分けて見てください。

次の行動

まず N を 5 から 7 に変えながら brute(5) から brute(7) を実行し、表の数と一致するかを確かめてください。次に randomGame() で基準線を取り、ふたりで補助なしを10局遊んでみます。

形になったら、アソバースへの投稿が選べます。このコードは1ファイルの index.html で、サーバー通信がないので、HTMLの直接アップロードで公開できます。公開にはメールアドレスの確認が必要です。投稿作品向けの共通対戦APIは現在提供されていません。そのため、このゲームは「同じ画面で交互にクリックするふたり用」として作ることになります。