回転と反転で同じ形を見つけるゲーム
遊びをつくる執筆にClaudeを使用しています。

お手本の形が1つ、その下に候補が6つ並んでいる画面を考えてみます。候補のうち1つだけが、お手本を回したり裏返したりすると重なる形で、残りの5つは1マスだけ違います。プレイヤーに求められるのは、頭の中で形をひっくり返して見比べ、同じものを選ぶことだけです。 この遊びは作りやすい反面、調整を誤ると退屈になります。ダミーが遠すぎれば一目で分かりますし、近すぎれば当て推量になります。そこで、ルールを決めることと、見比べが遊びとして成立する条件を探すことを分けて考えます。後者は、実際に動かして記録を取らないと答えが出ません。この記事では「回転と反転で同じ形を見つける」ゲームの核を最小のコードで作り、難しさを測るための実験の設計書を示します。結果は書きません。数字は、読者が自分で遊んで取ります。 確かめる問いは次の2つです。
「同じ形」を機械に決めさせる
見比べを遊びにするには、まず正解を機械が間違いなく決められる必要です。その方法が、形ごとに代表となる表記(正準形)を1つ決めることです。
手順は次のとおりです。
- 形をマス座標の集まりとして持ちます。
- 全体を90度ずつ回した4通りを作ります。反転を許す場合は、裏返した形の4通りも加えて計8通りにします。
- 各形を左上に寄せて並べ直し、文字列にします。
- 8通り(反転なしなら4通り)の文字列のうち、辞書順で最小のものを代表とします。
2つの形の代表が一致すれば、その2つは回転・反転で重なります。これで「同じかどうか」の判定が1行の比較になります。
反転を許すかどうかは、遊びの性格を大きく変えます。5マスの形(ペントミノ)は、回転と反転を同じとみなすと12種類です。回転だけを同じとみなすと18種類になります。差の6種類は、裏返した形が回転では元に重ならないものです。反転なしのルールでは、この6種類が「見た目は似ているのに別物」という罠になります。4マスの形(テトロミノ)でも同様に、5種類が7種類に増えます。
準備と最小コード
準備するものは、ブラウザと、下のコードを保存したHTMLファイル1つだけです。外部ライブラリもサーバーも使いません。
FLIP を true にすると反転を許し、false にすると回転だけを許します。ダミーは、お手本の端のマス(隣が1つだけのマス)を1つ外して別の場所に付け直して作ります。端のマスを外すので、形は必ず連結したままです。
<!doctype html><meta charset="utf-8">
<body><div id="app"></div><p id="msg"></p>
<script>
const N=5,K=6,FLIP=true,D=[[1,0],[-1,0],[0,1],[0,-1]],log=[];let t0,ans;
const key=c=>c.map(p=>p.join()).sort().join(";");
const norm=c=>{const a=Math.min(...c.map(p=>p[0])),b=Math.min(...c.map(p=>p[1]));return c.map(([x,y])=>[x-a,y-b]);};
const rot=c=>c.map(([x,y])=>[y,-x]), flip=c=>c.map(([x,y])=>[-x,y]);
function canon(c,f){let best=null;for(let i=0;i<(f?2:1);i++){for(let r=0;r<4;r++){const k=key(norm(c));if(best===null||k<best)best=k;c=rot(c);}c=flip(c);}return best;}
const has=(c,x,y)=>c.some(p=>p[0]==x&&p[1]==y), rnd=n=>Math.random()*n|0;
function grow(){const c=[[0,0]];while(c.length<N){const [x,y]=c[rnd(c.length)],[dx,dy]=D[rnd(4)];if(!has(c,x+dx,y+dy))c.push([x+dx,y+dy]);}return c;}
function mutate(c){
const leaves=c.filter(p=>D.filter(([dx,dy])=>has(c,p[0]+dx,p[1]+dy)).length==1);
const r=leaves[rnd(leaves.length)],rest=c.filter(p=>p!==r);
const [x,y]=rest[rnd(rest.length)],[dx,dy]=D[rnd(4)];
return has(rest,x+dx,y+dy)?c:[...rest,[x+dx,y+dy]];}
const tr=c=>{if(FLIP&&Math.random()<.5)c=flip(c);for(let i=rnd(4);i>0;i--)c=rot(c);return c;};
const draw=c=>{const n=norm(c),w=Math.max(...n.map(p=>p[0]))+1,h=Math.max(...n.map(p=>p[1]))+1;let s="";
for(let y=0;y<h;y++){for(let x=0;x<w;x++)s+=has(n,x,y)?"■":"・";s+="\n";}return s;};
function round(){
const a=grow(),k=canon(a,FLIP),opts=[{c:tr(a),ok:1}];
while(opts.length<K){const m=mutate(a),mk=canon(m,FLIP);
if(mk!=k&&!opts.some(o=>canon(o.c,FLIP)==mk))opts.push({c:tr(m),ok:0});}
for(let i=K-1;i>0;i--){const j=rnd(i+1);[opts[i],opts[j]]=[opts[j],opts[i]];}
app.innerHTML="<pre>"+draw(a)+"</pre>"+opts.map((o,i)=>`<button data-i="${i}"><pre>${draw(o.c)}</pre></button>`).join("");
ans=opts;t0=performance.now();}
app.onclick=e=>{const b=e.target.closest("button");if(!b)return;
log.push({flip:FLIP,k:K,ok:ans[b.dataset.i].ok,ms:Math.round(performance.now()-t0)});
msg.textContent=log.length+"回目 / "+JSON.stringify(log[log.length-1]);console.log(JSON.stringify(log));round();};
round();
</script>ブラウザの開発者ツールのコンソールを開くと、回答のたびに log 全体が出力されます。アップロード作品の中の保存は一時メモリー扱いなので、記録を残したい場合は、コンソールの出力をその場でコピーして表計算に貼ります。
手順:変える条件は一度に一つだけ
比較は、1つの条件だけを動かして行います。ここでは2つの実験に分けます。
実験A:反転を許す/許さない
K は6のまま、FLIP だけを true と false で切り替えます。どちらも同じ回数を遊びます。
- 1つの条件につき、練習5回に続けて本番20回を目安にします。練習分は集計から外します。
- 慣れの影響を打ち消すため、順番は A→B→B→A のように入れ替えます。
- 可能なら、そのゲームを初めて見る人にも同じ手順で遊んでもらいます。自分で作った人は、ダミーの癖を覚えてしまっているからです。
実験B:候補の数
FLIP を固定し、K を3、6、9のように変えます。ここでも1回の入力ごとに記録します。K を増やすと、ダミーの種類が足りずに while が終わらなくなる場合があります。5マスの形は12種類しかないので、K は12以下にします。
記録の取り方
コードが出すのは、条件(flip、k)、正誤(ok)、所要ミリ秒(ms)です。これを1行1回答の表にします。
- 条件、単位:実験名・FLIP・K、備考:条件ごとに別の表にします
- 正誤、単位:1か0、備考:練習は別の表に分けます
- 所要時間、単位:ミリ秒、備考:提示から選択まで
- 気づいたこと、単位:自由記述、備考:迷った形や、見分けた手がかり
集計では、条件ごとに次の3つを出します。
- 正答率(正解数 ÷ 回数)
- 所要時間の中央値。平均は、うっかり放置した1回に引きずられるので使いません。
- 間違えた回の所要時間。正解の回と比べます。
結果の読み方
数字が出たら、次の基準で判断します。
- 正答率が偶然の水準に近い:6択なら、でたらめに選んでも約17%は当たります。これに近い場合、見比べではなく当て推量になっています。ダミーが近すぎるか、候補が多すぎます。
- 正答率が高く、時間も短い:ダミーが遠すぎて、形を回さなくても見分けがつきます。端のマスを外して付け直すだけでなく、2マス動かすなど、近さを調整する余地があります。
- 正答率は高いが時間が長い:頭の中で回して確かめている状態で、見比べとしては健全な範囲に入りやすい領域です。ただし、時間が長すぎて疲れる遊びになっていないかは、遊んだ人の感想も併せて確かめます。
- 間違えた回の時間が極端に短い:よく見ずに選んでいる可能性があります。ダミーのうち、どの形を選んだかを後から記録に足すと、迷いやすい形の傾向が分かります。
- 実験Aで差が大きい:反転を許さないルールでは、裏返した形が罠として働きます。その手応えを楽しい難しさと見るか、理不尽と見るかは、感想の欄と合わせて判断します。
何より先に決めておくのは、どの数字なら「これでよい」とするかの基準です。結果を見てから基準を決めると、どんな数字でも都合よく読めてしまいます。実験の前に、正答率と時間の目標をそれぞれ書き出しておきます。
誤りやすい点
この実験でつまずきやすいところを挙げます。
- 正準形の比較で位置を揃え忘れる:左上に寄せる処理(norm)がないと、同じ形でも別の形として扱われます。ダミーに正解と同じ形が混ざり、正解が2つになります。
- 反転のフラグがずれる:canon の判定と tr(表示用の変換)で FLIP の扱いが食い違うと、見た目では重ならないのに正解扱いになります。両方が同じ FLIP を見ているか確かめます。
- 候補の並べ替えの偏り:sort(() => Math.random() - .5) で並べ替えると、位置に偏りが出ます。コードでは各位置を順に入れ替える方式を使っています。正解の位置が毎回同じ場所に寄っていないかも、記録で確かめます。
- 形の難しさのばらつき:5マスの直線のように、回しても見分けやすい形が出ると、そのラウンドだけ極端に簡単になります。条件ごとの差を見たいなら、お手本の形を固定して回転・反転だけを変える方法もあります。
- 学習の混入:同じ人が何度も遊ぶと、形の見方に慣れて時間が縮みます。条件の順番を入れ替える理由はここにあります。
- 少ない回数で結論を出す:数回の結果では偶然の影響が大きく、条件の差か揺れかを区別できません。回数を増やすか、別の日に繰り返します。
次の行動
まず上のコードを保存し、FLIP を true にして20回遊んで、記録を取ってみてください。その後に false へ切り替えて同じ回数を遊ぶと、反転の有無による差が自分の数字で見えます。手応えの良い条件が見つかったら、HTMLを1つにまとめたまま index.html として投稿できます。アップロードは20MiB以内で、サーバー用のコードは不要です。

