Reactで、カードをめくる神経衰弱ゲームを作る方法

​​React​CSS,Playwright,TypeScript

神経衰弱のゲームの中心になる 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、スマートフォンのブラウザでのタップ、スクリーンリーダーでの読み上げは試していません。

水色の裏面のカードが4列3段に12枚並び、左上に「手数:0」、右上に「クリック 0回目/6回」と出ている。カーソルが1枚目と2枚目を押すと「イヌ」と「トリ」が表になり、手数が1に増えて、少したってから2枚とも裏に戻る。次に1枚目と9枚目を押すと「イヌ」が2枚そろって表のまま残り、手数が2になる。最後に3枚目と7枚目で「ネコ」がそろい、手数が3になる
カードを6回押して、1組目は外れて裏に戻り、2組目と3組目がそろう様子。カーソル、押した瞬間の輪、回数の表示、手数が増えたときのピンクは GIF 用に重ねたもの。手数の文字を大きくし、「もう一度」ボタンは隠している

絵柄を2枚ずつにして、偏りなく混ぜる

カードの並びは、React から切り離した関数で作りました。絵柄ごとに2枚のカードを作り、Fisher–Yates のシャッフルで混ぜます。

ts
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万回になります。

text
並べ方は 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種類を渡しました。

tsx
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枚目を、0ms、50ms、100ms、150ms の4つの時点で横に並べた画像。0ms と 50ms では白い表の面に「サカナ」が見え、100ms では細くなった表の面に「サカナ」が見え、150ms では水色の裏の面に「?」が見える
位置を key にしたものでは、裏に戻るまでの間、新しい並びの1枚目の絵柄(サカナ)が見えている

新しい並びの1枚目はサカナで、0.1秒の時点でも、まだ表の面にサカナが見えていました。React が同じボタンを使い回し、中の文字だけを新しい絵柄に変えてから、CSS の transition で裏に戻しているためです。新しいゲームの最初の1枚が、遊ぶ前に分かってしまいます。記事のコードでは、「もう一度」の直後の1枚目に動いているアニメーションはなく、最初から裏の向きでした。

React の公式ドキュメントにある、リストの key の説明でも、配列を並び替えるときにインデックスを key にすると、微妙でややこしいバグの原因になると書かれています。

カードを裏返す CSS

カードを裏返す CSS は次のとおりです。表と裏の面を重ねて、2つの面を包む .inner を Y 軸で 180 度回します。

css
.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で、カードを裏返すアニメーションを作る方法」で確かめています。

​Posted by 86