30分で作る神経衰弱:カードの裏返しと一致判定
遊びをつくる執筆にClaudeを使用しています。

神経衰弱は、ルールだけ見ると小さなゲームです。同じ絵柄のカードを2枚そろえれば取れ、そろわなければ裏に戻します。ところが作り始めると、「2枚めくって違ったとき、いつ裏に戻すか」で必ず迷います。すぐ戻すと何が出たのか見えず、少し待つとその間に3枚目を押せてしまいます。 この記事で確かめるのは、カード1枚ごとの状態を「裏・表・取得済み」の3つに絞り、1つの変数で入力を止めれば、最小の神経衰弱がHTML 1枚で動くかという点です。手順は、設計、入力、動作確認、わざと壊す、の4段階です。30分は目安で、設計に5分、入力に10分、確認に5分、壊して読み解くのに10分ほどを見込んでください。 使うのはテキストエディタとブラウザだけです。外部ライブラリもサーバーも要りません。
この記事の目次
手順1:カードの状態を3つに絞る(設計・5分)
最初にコードを書かず、1枚のカードが取りうる状態を決めます。ここを曖昧にすると、後で「めくったまま固まるカード」が生まれます。
- down、見た目:裏(「?」と表示)、押せるか:押せる
- up、見た目:表(絵柄が見える)で、判定待ち、押せるか:押せない
- matched、見た目:取得済み(緑)、押せるか:押せない
カードの絵柄と状態は、1枚ごとに {symbol: '🍎', state: 'down'} というオブジェクトで持ちます。これを並べた配列がゲームのすべてです。画面は、この配列を読んで毎回描き直します。「画面を直接いじらず、状態を書き換えてから描き直す」と決めておくと、バグの原因が状態の遷移に絞られます。
あと2つ、ゲーム全体の変数を用意します。
- open:いま表になっているカードの番号を入れる配列です。長さは0〜2になります。
- locked:不一致を見せている間だけ true にする、入力停止のスイッチです。
状態の流れは次のとおりです。
- 裏のカードを押すと、up になり open に番号が入ります。
- open が2枚になったら、絵柄を比べます。
- 一致なら2枚とも matched にして open を空にします。
- 不一致なら locked を true にし、少し待ってから2枚を down に戻し、open を空にして locked を false に戻します。
手順2:index.htmlに書き写す(入力・10分)
次のコードを index.html という名前で保存します。59行で、単独で動きます。絵柄は8種類を2枚ずつ並べた16枚です。
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>神経衰弱</title>
<style>
body{font-family:sans-serif;text-align:center}
#board{display:grid;grid-template-columns:repeat(4,64px);gap:8px;justify-content:center;margin:16px auto}
#board button{height:64px;font-size:28px;border:0;border-radius:8px}
.down{background:#357;color:#fff}
.up{background:#fff;outline:2px solid #357}
.matched{background:#9c9}
</style>
<h1>神経衰弱</h1>
<p id="msg"></p>
<div id="board"></div>
<button id="reset">もう一度</button>
<script>
const symbols=['🍎','🍋','🍇','🍒','🌙','⭐','🐟','🐚'];
let cards,open,locked,moves;
function start(){
const deck=[...symbols,...symbols];
for(let i=deck.length-1;i>0;i--){
const j=Math.floor(Math.random()*(i+1));
[deck[i],deck[j]]=[deck[j],deck[i]];
}
cards=deck.map(s=>({symbol:s,state:'down'}));
open=[];locked=false;moves=0;
draw();
}
function draw(){
const board=document.getElementById('board');board.innerHTML='';
cards.forEach((c,i)=>{
const b=document.createElement('button');
b.className=c.state;
b.textContent=c.state==='down'?'?':c.symbol;
b.onclick=()=>flip(i);
board.append(b);
});
const done=cards.every(c=>c.state==='matched');
document.getElementById('msg').textContent=done?'クリア! '+moves+'手':moves+'手';
}
function flip(i){
if(locked||cards[i].state!=='down')return;
cards[i].state='up';open.push(i);
if(open.length===2){moves++;check();}
draw();
}
function check(){
const [a,b]=open;
if(cards[a].symbol===cards[b].symbol){
cards[a].state=cards[b].state='matched';open=[];
}else{
locked=true;
setTimeout(()=>{cards[a].state=cards[b].state='down';open=[];locked=false;draw();},800);
}
}
document.getElementById('reset').onclick=start;
start();
</script>書き写すときの要点は4つあります。
- start():絵柄を2枚ずつ並べて混ぜ(フィッシャー–イェーツ法という、後ろから順に入れ替える混ぜ方です)、全カードを down にします。「もう一度」ボタンもこの関数を呼ぶだけです。
- draw():ボタンを毎回作り直します。状態名をそのままCSSのクラス名にしているので、見た目の切り替えは b.className=c.state の1行で済みます。
- flip(i):最初の1行が門番です。locked が立っているか、押されたカードが down でなければ何もしません。
- check():一致判定は絵柄の文字列比較だけです。不一致のときだけ setTimeout で800ミリ秒後に戻します。この800は、見る時間の長さを決める数字です。
手順3:動作を確かめる(確認・5分)
保存した index.html をブラウザで開き、次の順に確認します。結果は、それぞれ画面で自分の目で見てください。
- 起動直後に、16枚すべてが「?」の裏向きで並んでいるか。
- 1枚押すと、そのカードだけ表になり、手数は変わらないか。
- 2枚目を押した時点で手数が1増えるか。
- 絵柄が違うとき、約0.8秒表のまま見えてから2枚とも裏に戻るか。
- 絵柄が同じとき、2枚が緑のまま残り、押しても反応しないか。
- 緑のカードや、表になっているカードを押しても、何も起きないか。
- 全部取ると「クリア!」と手数が出るか。
- 「もう一度」で配置が変わり、手数が0に戻るか。
どれかが違った場合は、次の対応表で原因の見当をつけます。
- 何を押しても反応しない、見直す場所:<script> の位置、start() の呼び出し
- 一致しているのに戻る、見直す場所:check() の比較(===)と symbol の綴り
- 手数が2ずつ増える、見直す場所:moves++ が2か所にないか
- 見た目が変わらない、見直す場所:CSSのクラス名と状態名の一致
手順4:わざと壊して、2つの仕組みの役割を知る(検証・10分)
動いたら、仕組みを2か所だけ外してみます。「なぜこの1行があるのか」を、自分の目で確かめるための手順です。壊す前に、元のファイルを別名でコピーしておいてください。
壊す1:入力停止を外す
flip の最初の行から locked|| を消します。
if(cards[i].state!=='down')return;不一致の0.8秒の間に、別の裏カードを押してみてください。コードを論理的に追うと、次のことが起きるはずです。
- 3枚目が up になり、open は3枚になります。
- open.length===2 を満たさないので、check() は呼ばれません。
- 800ミリ秒後、戻されるのは a と b だけで、open は空になります。
- 3枚目は up のまま、open の管理から外れて取り残されます。
取り残されたカードは down ではないので、flip の門番に弾かれ、二度と押せません。実際にそうなるかを、自分の画面で確かめてください。ゲームを終えられなくなるのは、locked が「待ち時間中の状態の食い違い」を防ぐ役目だからです。
壊す2:「裏のカードだけ押せる」を外す
元に戻してから、今度は flip の最初の行を次のようにします。
if(locked)return;同じ裏カードを続けて2回押してみてください。論理的には、1回目で open=[i]、2回目で open=[i,i] となります。check() は同じカード同士の絵柄を比べ、必ず一致します。つまり1枚を2回押すだけで取得済みになるはずです。
この2つから、次の考え方が確認できます。
- 入力の門番は「いま押してよいか」を一か所で決めます。
- 押してよいかどうかを、見た目ではなく状態で決めると、押し間違いの経路が減ります。
手順5:拡張の方向と、アソバースに出す前の確認
動くところまで来たら、次の拡張を1つずつ試せます。どれも、状態の持ち方を壊さずに足せます。
- 枚数を変える:symbols の絵柄を減らせば簡単に、増やせば難しくなります。列数は grid-template-columns の repeat(4,64px) で調整します。
- ミスの回数を数える:check() の不一致側に misses++ を足し、クリア時に表示します。
- 記録を比べる:手数が少ないほど良いので、前回の手数を変数で持ち、画面に並べると再挑戦したくなります。なお、アソバースにはゲーム内スコアの共通ランキングは未提供です。記録は自作の表示で見せることになります。
- 戻すまでの時間を調整する:800を400や1500に変えると、緊張感や遊びやすさが変わります。設定を一つ決める前に、数値を変えて複数回遊んで比べると納得して決められます。
アソバースへ出す場合は、index.html が入口になります。HTMLの直接アップロードでも、ZIPでも投稿できます。
- 流れは「入力、表示の確認、公開」の3段階で、途中で下書き保存もできます。
- 公開にはメールアドレスの確認が必要です。
- この記事のコードは単独のHTMLで外部通信がなく、アップロードの上限(20MiB以内)にも十分収まります。
投稿の画面にある「紹介文と遊び方を作成」で、紹介文の下書きを作れます。「この文章を使う」を押した場合だけ反映されるので、内容を読んでから選んでください。
次の行動
まず index.html を保存し、手順3の8項目を順に確認してください。そのあと、手順4のどちらか1つを試して、locked と「裏のカードだけ押せる」判定の役割を確かめましょう。最後に、絵柄の数か戻すまでの時間のどちらか1つだけを変え、遊び心地の違いを自分の画面で見てください。
