Reactで、カードをめくる神経衰弱ゲームを作る方法
神経衰弱のゲームの中心になる state は3つです。カードの並び、いまめくっている途中のカードの位置(最大2枚)、そろった絵柄です。2枚目をめくったときに絵柄を比べ、同じならそろった絵柄に足し、違えば少し見せてから setTimeout で裏に戻します。
動かすだけなら短いコードで済みますが、遊ぶと崩れる箇所がいくつかあります。2枚見えている間の3枚目のクリック、同じカードの2度押し、「もう一度」を押したときに前のゲームから残るタイマーと表の面です。それぞれの確認を外したものと比べて、何が起きるかを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で操作しました。Firefox と Safari、スマートフォンのブラウザでのタップ、スクリーンリーダーでの読み上げは試していません。

絵柄を2枚ずつにして、偏りなく混ぜる
カードの並びは、React から切り離した関数で作りました。絵柄ごとに2枚のカードを作り、Fisher–Yates のシャッフルで混ぜます。
export type Card = { id: string; symbol: string };
// 配列を並べ替えた新しい配列を返す(Fisher–Yates のシャッフル)
// rand: 0以上1未満の数を返す関数
export function shuffle<T>(items: T[], rand: () => number = Math.random): T[] {
const a = [...items];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// 絵柄を2枚ずつにして、混ぜたカードの並びを作る
export function createDeck(symbols: string[], rand: () => number = Math.random): Card[] {
const cards = symbols.flatMap((symbol, i) => [
{ id: `${i}a`, symbol },
{ id: `${i}b`, symbol },
]);
return shuffle(cards, rand);
}
shuffle は、配列の後ろから順に、その位置より前(自分を含む)からランダムに選んだ1つと入れ替えます。id は、同じ絵柄の2枚を見分けるために 0a、0b のように付けました。6種類の絵柄を渡すと、12枚で、どの絵柄も2枚ずつ、id の重複もない並びになりました。
配列を混ぜる書き方には、sort(() => Math.random() - 0.5) もあります。Node.js で、Fisher–Yates と比べました。4つの値(A、B、C、D)の並べ方は24通りなので、24万回混ぜれば、偏りがなければ1通りあたり約1万回になります。
並べ方は 4! = 24 通り。240000回ずつ混ぜた(1通りあたりの期待値 10000)
Fisher–Yates: {"kinds":24,"min":9714,"max":10125,...}
sort(() => Math.random() - 0.5): {"kinds":24,"min":3661,"max":45066,"top":[["ABCD",45066],["DCBA",41147]],...}
Fisher–Yates は、どの並び方も 9,714〜10,125 回でした。sort を使ったほうは、元のままの「ABCD」が 45,066 回、逆順の「DCBA」が 41,147 回と多く、少ない並び方は 3,661 回でした。12個を混ぜて、先頭に来た元の位置を数えても、sort では元の1番目が 41,241 回(期待値は2万回)、9番目が 12,699 回のように偏りました。
MDN の Array.prototype.sort() の説明には、比較関数は同じ組の入力に対して常に同じ結果を返す必要がある、と書かれています。Math.random() を返す比較関数は、この条件に合いません。
カードをめくって、2枚を比べるコンポーネント
コンポーネントの全体は次のとおりです。絵柄の文字列の配列を symbols で受け取ります。検証では、ネコ、イヌ、トリ、サカナ、ウサギ、クマの6種類を渡しました。
import { useRef, useState } from 'react';
import { createDeck, type Card } from './deck';
type Props = { symbols: string[]; delay?: number };
export function MemoryGame({ symbols, delay = 800 }: Props) {
const [cards, setCards] = useState<Card[]>(() => createDeck(symbols));
const [open, setOpen] = useState<number[]>([]); // めくっている途中のカード(位置、最大2枚)
const [matched, setMatched] = useState<string[]>([]); // そろった絵柄
const [moves, setMoves] = useState(0);
const [round, setRound] = useState(0);
const timer = useRef<number | null>(null);
function clearTimer() {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
}
function flip(i: number) {
// 2枚めくっている間、同じカード、そろったカードは受け付けない
if (open.length === 2 || open.includes(i) || matched.includes(cards[i].symbol)) return;
if (open.length === 0) {
setOpen([i]);
return;
}
const first = open[0];
setMoves((m) => m + 1);
if (cards[first].symbol === cards[i].symbol) {
setMatched((m) => [...m, cards[i].symbol]);
setOpen([]);
} else {
// 2枚とも見せてから、少したって裏に戻す
setOpen([first, i]);
timer.current = window.setTimeout(() => {
timer.current = null;
setOpen([]);
}, delay);
}
}
function reset() {
clearTimer();
setCards(createDeck(symbols));
setOpen([]);
setMatched([]);
setMoves(0);
setRound((r) => r + 1);
}
const cleared = matched.length === symbols.length;
return (
<div className="memory">
<p className="moves">手数:{moves}</p>
{/* round が変わると、カードを作り直す(前のゲームのカードを使い回さない) */}
<div className="grid" key={round}>
{cards.map((card, i) => {
const done = matched.includes(card.symbol);
const faceUp = done || open.includes(i);
return (
<button
key={card.id}
type="button"
className={`card${faceUp ? ' up' : ''}${done ? ' done' : ''}`}
onClick={() => flip(i)}
disabled={done}
aria-label={faceUp ? card.symbol : `${i + 1}枚目(裏)`}
>
<span className="inner">
<span className="face back" aria-hidden="true">?</span>
<span className="face front" aria-hidden="true">{card.symbol}</span>
</span>
</button>
);
})}
</div>
<p className="status" role="status">{cleared ? `クリア! ${moves}手でそろいました` : ''}</p>
<button type="button" className="reset" onClick={reset}>もう一度</button>
</div>
);
}
カードが表かどうかは、state には持たせず、描画のときに open と matched から求めています。そろった絵柄の2枚と、めくっている途中のカードが表です。そろったカードは disabled にして、押せなくしました。
最初の並びは、useState に createDeck(symbols) の結果ではなく、() => createDeck(symbols) という関数を渡して作っています。React の useState のリファレンスにある、初期 state の再生成を避ける方法のとおり、関数を渡すと、React は初期化のときにだけ呼び出します。結果を渡す書き方だと、描画のたびにシャッフルが実行されます(使われるのは最初の結果だけです)。
検証環境で、Math.random をシードを固定した乱数に置き換え、Playwright で操作したところ、並びは「イヌ トリ ネコ サカナ ウサギ クマ ネコ ウサギ イヌ トリ サカナ クマ」でした。1枚目と2枚目(イヌとトリ)をめくると、2枚とも表になり、手数が1になりました。外れた2枚は、delay で決めた時間(既定は800ミリ秒)がたつと裏に戻ります。検証では、2枚をめくってから約0.4秒後は表のままで、約0.9秒後には2枚とも裏に戻りました。1枚目と9枚目(どちらもイヌ)をめくると、2枚とも表のまま残り、手数は2になりました。残りの5組を続けてそろえると、手数は7で、「クリア! 7手でそろいました」と表示されました。
カードは <button> なので、キーボードでも遊べます。1枚目にフォーカスして Enter、Tab で2枚目に移って Space を押すと、2枚ともめくれて、手数が1になりました。裏のカードの名前は「1枚目(裏)」のように位置だけにし、絵柄は表になったときだけ aria-label に出しています。
2枚見えている間のクリックと、同じカードの2度押しを受け付けない
flip の最初の if には、クリックを受け付けない条件を3つ並べました。そのうち、そろったカードの条件を除く2つは、外すとゲームが正しく進まなくなります。
1つ目の open.length === 2 は、外れた2枚が見えている間のクリックを受け付けないためのものです。記事のコードでは、イヌとトリが見えている間に3枚目を押しても、表のカードは1枚目と2枚目のままで、手数も1のままでした。
この確認を外したものでは、イヌとトリをめくった0.3秒後に3枚目(ネコ)を押すと、3枚目が1枚目と組になって比べられ、手数が2に増えました。2枚目は裏に戻り、表は1枚目と3枚目になりました。ところが、最初の2枚をめくってから0.85秒後には、どちらも裏に戻っていました。最初の組で仕掛けたタイマーが、3枚目をめくってから0.5秒しかたっていないのに、1枚目と3枚目まで裏に戻したためです。続けて1枚目と同じイヌの9枚目を押しても、1枚目はもう裏なので、そろいませんでした。
2つ目の open.includes(i) は、同じカードを2回押したときのためのものです。記事のコードでは、1枚目を2回押しても、表は1枚目だけで、手数は増えませんでした。この確認を外したものでは、1枚目を2回押すと、1枚目と1枚目を比べて同じ絵柄と判定し、イヌがそろった扱いになりました。押していない9枚目のイヌまで表になり、1枚目は押せなくなりました。
「もう一度」では、タイマーを止めてからカードを作り直す
reset は、最初に clearTimer で、裏に戻すタイマーを止めています。clearTimer の中で呼んでいる clearTimeout() は、MDN の clearTimeout() の説明にあるとおり、setTimeout() で仕掛けたタイムアウトを解除するメソッドです。タイマーの ID は、描画に使わないので、state ではなく useRef に入れました。
clearTimer を呼ばないものと比べました。外れた2枚をめくって0.2秒後に「もう一度」を押し、新しいゲームで3枚目をめくります。その0.8秒後、clearTimer を呼ばないものでは、めくったばかりの3枚目が裏に戻っていました。前のゲームで仕掛けたタイマーが、新しいゲームの open を空にしたためです。記事のコードでは、3枚目は表のままでした。
reset の最後では round を1増やしています。この round を、カードを並べる <div className="grid"> の key に使っています。React の公式ドキュメントにある、key で state をリセットする方法と同じ考え方で、key が変わると、React は中のボタンを前のゲームのものとは別のものとして作り直します。
grid の key を外し、さらにカードの key を位置(i)にしたものと比べました。こうすると、表だったカードが裏に戻るアニメーションの間に、新しい並びの絵柄が見えてしまいます。1枚目(イヌ)を表にしてから「もう一度」を押し、1枚目の transition を止めて、時刻を少しずつ進めて撮ったのが次の画像です。

新しい並びの1枚目はサカナで、0.1秒の時点でも、まだ表の面にサカナが見えていました。React が同じボタンを使い回し、中の文字だけを新しい絵柄に変えてから、CSS の transition で裏に戻しているためです。新しいゲームの最初の1枚が、遊ぶ前に分かってしまいます。記事のコードでは、「もう一度」の直後の1枚目に動いているアニメーションはなく、最初から裏の向きでした。
React の公式ドキュメントにある、リストの key の説明でも、配列を並び替えるときにインデックスを key にすると、微妙でややこしいバグの原因になると書かれています。
カードを裏返す CSS
カードを裏返す CSS は次のとおりです。表と裏の面を重ねて、2つの面を包む .inner を Y 軸で 180 度回します。
.grid { display: grid; grid-template-columns: repeat(4, 72px); gap: 8px; }
.card {
width: 72px; height: 96px; padding: 0; border: none; background: none;
perspective: 600px; cursor: pointer;
}
.card:disabled { cursor: default; }
.inner {
position: relative; display: block; width: 100%; height: 100%;
transform-style: preserve-3d; transition: transform 0.4s;
}
.card.up .inner { transform: rotateY(180deg); }
.face {
position: absolute; inset: 0; display: grid; place-items: center;
border-radius: 8px; backface-visibility: hidden; font-weight: bold;
}
.back { background: #00c8e8; color: #fff; font-size: 28px; }
.front { font-size: 18px; background: #fff; border: 2px solid #00c8e8; color: #1f2a30; transform: rotateY(180deg); }
.card.done .front { background: #eefafd; }
@media (prefers-reduced-motion: reduce) {
.inner { transition: none; }
}
動きを減らす設定のときは、transition を外して、すぐに切り替えます。Playwright で動きを減らす設定(reducedMotion: 'reduce')にすると、.inner の transition-duration は 0s になりました。裏に戻すタイミングは transitionend ではなく setTimeout で決めているので、transition がなくても裏に戻ります。この設定のまま操作すると、外れた2枚は0.9秒後に裏に戻り、そろった2枚は表のまま残りました。どの指定が欠けると裏返しが崩れるかは、このブログの「Reactで、カードを裏返すアニメーションを作る方法」で確かめています。





