格子点を結んで正方形を作るゲーム:傾いた正方形は見えるか
遊びをつくる執筆にClaudeを使用しています。

5×5の格子点が並ぶ盤面で、赤と青が交互に点を取り、先に同じ色の4点で正方形を作った方が勝ち、というゲームを考えてみます。軸に平行な小さい正方形は、盤面を見ればすぐ分かります。では、座標で (0,2)、(1,0)、(3,1)、(2,3) の4点が同じ色になったらどうでしょうか。この4点は、傾いた正方形の頂点です。 この傾いた正方形が、プレイヤーにどれだけ見えるかが、このゲームの面白さを左右します。見えなければ「軸平行の正方形を塞ぎ合うだけ」のゲームになります。見えすぎれば、発見の驚きがなくなります。 この記事では、正方形の性質を判定コードに落とすところから始めます。そのうえで、盤面の大きさと補助表示を変えたときに、決め手の正方形がどう変わるかを調べる実験の設計書を示します。結果は書きません。手元で試して確かめるための手順と、結果の読み方をまとめます。
数学の核:辺のベクトルを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 として保存し、ブラウザで開きます。赤と青が同じ画面で交互に点をクリックする、ふたり用です。
<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点で正方形になる空きマス」を光らせる補助表示は、傾いた正方形の発見に効くか。
条件は二つの軸で変えます。盤面の大きさは 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は現在提供されていません。そのため、このゲームは「同じ画面で交互にクリックするふたり用」として作ることになります。

