4.5 神経衰弱ゲームを作る

(1/1)
神経衰弱ゲームの画面イメージ
AI生成コンテンツ / AI-generated content
4.4 落ち物パズルゲーム」までのゲームは、いずれも画面を HTML5 Canvas に絵として描いてきた。本項で作る「神経衰弱ゲーム」(memoryGame.html)は、その方針を変える。52枚のカードをHTML要素そのものとして画面に並べるのである。カード1枚がボタン1個に対応し、めくる動きはCSSに任せる。
このやり方に切り替えると、キーボード操作や読み上げソフトへの対応が簡単になり、そのかわりCanvasとは別の作法が必要になる。本項では、ゲームのルールを整理したうえで、HTML要素による盤面、CSS Gridによる配置、CSSの3D変換によるカードめくり、data-属性による状態の持ち方、setTimeoutによる遅延と入力ロック、キーボードと読み上げへの対応、カード画像の検証とフォールバックという7つの要素技術を解説する。あわせて、「4.4」で危険があると書いた innerHTML を本項では使っている理由と、仕様書と実装の食い違いにも触れ、最後にCodexへ渡すプログラム仕様書をまとめる。

目次

神経衰弱ゲームの概要

神経衰弱ゲームの開始画面
神経衰弱ゲームの開始画面
AI生成コンテンツ / AI-generated content
神経衰弱は、裏返して並べたトランプから2枚ずつめくり、同じ数字(ランク)の組を探すゲームである。英語では Concentration(集中)と呼ばれるほか、MemoryMatching PairsPairs、英国では Pelmanism、中欧では Pexeso とも呼ばれる。特別な道具は要らず、トランプが1組あれば成立し、子どもから大人まで同じルールで遊べる。日本語の「神経衰弱」という名は、集中して神経を使い続けることに由来する。

標準的な遊び方では、ジョーカーを除く52枚を4行×13列に並べる。本項で作る memoryGame.html も、パソコンの画面ではこの4行13列を基本の配置とする。人数は1人から何人でも遊べるが、本項は1人用とし、対戦相手・得点・制限時間・ライフ・ヒント・特殊カード・アイテムはいっさい設けない。プレイヤーの成績は手数経過時間の2つだけで測る。

このゲームには、これまで作ってきたゲームにはなかった特徴がある。動いているものが1つもないということである。ブロック崩しのボールも、落ちものブロックの落下も存在しない。プレイヤーがカードをクリックした瞬間だけ画面が変わり、あとは止まっている。1秒間に何十回も画面を描き直す必要がないため、「4.3 ブロック崩し」で使った requestAnimationFrame によるゲームループも、「4.4 落ちものブロック」で使ったデルタタイムの計算も不要である。

動きがないぶん、画面の要素は多い。52枚のカードには、それぞれ「裏向き」「表向き」「ペア成立済み」という3つの状態があり、クリックできるかどうかも状態によって変わる。この52個の小さな部品をどう管理するかが、本項の中心的な課題になる。そして、この課題に対しては Canvas よりも HTML要素 のほうが素直に答えを出せる。
画面構成要素
要素役割
ゲーム盤52枚のカードを並べる領域。パソコンでは4行×13列、スマートフォンでは6列または8列に折り返す
カード1枚が1個のボタン。裏向き、表向き、ペア成立済みの3状態を見た目で区別する
ステータス表示成立ペア数(0 / 26)、手数、経過時間。画面上部に貼り付けて常に見えるようにする
操作ボタン「一時停止」「最初から」の2つ
メッセージ欄「ペア成立!」「違うランクです」やエラー内容を表示する
開始画面ゲームの目的、ペア成立条件、過去30回分の成績、「ゲーム開始」ボタン
クリア画面今回の手数と経過時間、「もう一度遊ぶ」「終了する」ボタン
操作説明画面下部に、マウス・キーボード・タッチの操作方法を表示する
なお、神経衰弱には数学的な研究もある。ヴェルマンとウォリントンの2013年(平成25年)の論文によれば、すべてのカードの位置を完璧に覚えていて、なおかつ最善の手順を選んだ場合でも、ペア数を n として期待手数はおよそ 1.61 × n になる。26組であれば約41手である。最少の26手で終わることは、まず起こらない。序盤は必ず「知らないカードを2枚めくる」ことになるからである。この事実は、プレイヤーが自分の記憶力を過小評価しないためにも知っておくとよい。

ゲーム・ルール

52枚の構成とペアが成立する条件
AI生成コンテンツ / AI-generated content
トランプの1枚1枚は、スート(絵柄)とランク(数字)という2つの情報を持っている。スートはスペード ♠、ハート ♥、ダイヤ ♦、クラブ ♣ の4種類、ランクは A、2、3、4、5、6、7、8、9、10、J、Q、K の13種類である。4 × 13 で52枚になる。ジョーカーは使わない。

ペアが成立する条件はランクが同じであること、ただそれだけである。スートは問わない。ハートの7とスペードの7はペアになる。同じランクのカードは4枚あるので、そのうち2枚が揃えば1組、残り2枚がもう1組となり、全体では 52 ÷ 2 で26組のペアが成立する。

ここは初心者がつまずきやすいところである。「同じカードを2枚探す」と説明すると、ハートの7とハートの7を探すのだと受け取られかねない。トランプには同じカードが2枚存在しないので、それでは永久に終わらない。仕様書にはスートが異なってもランクが同じであればペア成立とすると明記し、さらに「例として、ハートの7とスペードの7はペアとして扱う」という具体例を添えておく。Codexへの指示では、抽象的な規則の直後に具体例を1つ置くと解釈のぶれが減る
52枚の構成
項目内容
スートスペード ♠、ハート ♥、ダイヤ ♦、クラブ ♣4種類
ランクA、2、3、4、5、6、7、8、9、10、J、Q、K13種類
カード4種類 × 13種類52枚
同一ランクスートが4つあるため、どのランクも4枚ある4枚
ペア同一ランク4枚で2組が成立する26組
ゲームの流れは次のとおりである。「ゲーム開始」ボタンを押すと52枚をランダムに並べ替え、すべて裏向きで盤面に配置する。プレイヤーが裏向きのカードを1枚選ぶと、そのカードが表向きになる。続けて別の裏向きのカードを1枚選ぶと、2枚目も表向きになり、この時点で手数を1つ増やす。2枚のランクが同じならペア成立とし、表向きのまま固定して以後選べなくする。ランクが違うなら、プレイヤーが内容を確認できるよう約1秒間そのまま表示してから、2枚とも裏向きに戻す。

手数の数え方には、はっきりした決まりが要る。本項では2枚目を表向きにした回数を手数とする。ペアが成立してもしなくても1手である。1枚だけめくった状態では増やさない。この決め方をしておかないと、「1枚めくるたびに1手」と解釈されて手数が2倍になったり、「ペア成立時だけ1手」と解釈されて何度失敗しても手数が増えなかったりする。

経過時間は、最初のカードを選んだ瞬間から数え始める。ゲーム開始ボタンを押した瞬間ではない。開始画面を眺めている時間や、盤面が現れてから最初の1枚を決めるまでの時間を、成績に含めないためである。最後のペアが成立した瞬間に計測を止める。制限時間は設けない。プレイヤーが自分でやめない限り、いつまでも続けられる。
操作の種類
操作マウス/タッチキーボード
カードをめくる裏向きのカードをクリックまたはタップTabで移動し、EnterまたはSpace
一時停止・再開「一時停止」ボタンを押すEscまたはP
最初から「最初から」ボタンを押す(確認あり)Tabで移動し、EnterまたはSpace
一時停止には、このゲーム特有の事情がある。一時停止中は盤面を隠さなければならない。カードの配置を見続けたまま休憩できてしまっては、記憶ゲームとして成り立たないからである。落ちものブロックの一時停止は「時間を止める」だけで足りたが、神経衰弱では「情報を隠す」ことまで含めて仕様に書く必要がある。同じ「一時停止」という言葉でも、ゲームによって意味することが違う。

「最初から」を押したときは、いま遊んでいるゲームを捨ててよいか確認したうえで、カードを再シャッフルし、手数・経過時間・成立ペア数をすべてゼロに戻す。途中でやめたゲームは成績として保存しない。保存してしまうと、1組も揃えないうちにやり直せば「手数0、時間0」の最高記録ができあがってしまう。

26組すべてが成立した時点でゲームクリアとする。ゲームオーバーの条件は設けない。落ちものブロックのように「積み上がったら終わり」という失敗の形がないため、このゲームには負けがない。プレイヤーが競う相手は、過去の自分の手数と時間だけである。

要素技術1:HTML要素で盤面を作る

Canvasに描く方式とHTML要素を並べる方式の違い
AI生成コンテンツ / AI-generated content
本項からは、このゲームで使う要素技術を解説する。これらを理解できなくても、最後に掲げる仕様書(プロンプト)を Codex に投入すればゲーム・プログラムを作ることができる。のちのちゲームを改良したり、別のゲームを創作したりするときの手がかりとして読んでほしい。

第4章でこれまで作ってきたゲームは、いずれも HTML5 Canvas を使ってきた。Canvasとは、Webページの中に置く1枚の白紙である。プログラムは、その白紙に対して「ここに赤い四角を描け」「ここに丸を描け」と命令を出して絵を作る。3D迷路もブロック崩しも落ちものブロックも、この方式だった。

Canvasには重要な性質がある。描き終わったあとの絵には、部品という概念がないということである。ブロック崩しの画面に並ぶブロックは、プログラムから見れば配列に入った数値であって、画面の上では単なる色の付いた領域にすぎない。だからマウスでクリックされたとき、ブラウザは「どのブロックが押されたか」を教えてくれない。教えてくれるのは「画布の左上から右へ何ピクセル、下へ何ピクセルの位置が押された」ということだけである。どのブロックに当たるかは、プログラムが座標を計算して自分で突き止めなければならない。

神経衰弱でこの方式を採ると、次のような作業がすべて自前になる。カード52枚分の矩形の位置を計算する。クリック座標がどの矩形に入るかを調べる。キーボードのTabキーで順に選べるようにする。いま選ばれているカードに枠を描く。読み上げソフトに「ハートの7」と伝える。画面幅が変わったら全部の座標を計算し直す。どれも本質的な処理ではないのに、量が多く、間違えやすい

そこで本項では方針を変え、カード1枚をHTMLのボタン要素1個として画面に置く。HTMLの世界では、画面に見えているものはすべて要素(element)という部品であり、部品には名前と属性と中身がある。ブラウザは部品の位置と大きさを自分で管理しているので、クリックされたとき「この部品が押された」と正確に教えてくれる。この、部品の集まりとしてページを扱う仕組みを DOM(Document Object Model、文書オブジェクトモデル)と呼ぶ。

部品の種類として button を選んだことにも意味がある。見た目だけなら div という汎用の箱でも同じものが作れるが、buttonにはブラウザがあらかじめ用意した振る舞いが付いてくる。Tabキーで順に選べるEnterキーやSpaceキーで押せる読み上げソフトに「ボタン」として伝わる押せない状態(disabled)にできる。これらを自前で作ろうとすると数十行のプログラムになるが、buttonを使えば1行も書かずに手に入る。
Canvas方式とHTML要素方式の比較
項目Canvasに描くHTML要素を並べる
画面の作り方1枚の画布に命令で描く部品を置き、CSSで見た目を決める
クリック判定座標を計算して自分で調べる押された部品をブラウザが教える
キーボード操作自前で実装するbuttonなら標準で使える
読み上げ対応ほぼ不可能属性を付けるだけで対応できる
画面幅への追随座標を計算し直すCSSが自動で並べ直す
アニメーション毎回描き直すCSSに任せられる
得意なもの動きの多い画面、自由な形の描画止まっている画面、部品の多い画面
不得意なもの部品ごとの操作、支援技術への対応毎秒何十回も全面を描き替える処理
どちらが優れているという話ではない。画面の性質によって向き不向きがある。ブロック崩しのように、ボールが毎秒60回動き、画面全体を描き直す必要があるゲームでは、部品を52個も動かすより1枚の画布に描くほうが速い。神経衰弱のように、クリックした瞬間だけ変化し、部品ごとの操作が中心のゲームでは、部品として置くほうが素直である。仕様書には「Canvas APIまたはHTML要素を利用してゲーム画面を描画すること」と書き、どちらを選んでもよいという含みを持たせてある

52枚のカードのデータそのものは、Canvas方式のときと同じく JavaScript の配列で持つ。4つのスートそれぞれについて13のランクを作る、という二重の繰り返しで52個のデータを生成する。JavaScriptには flatMap という命令があり、「4回繰り返して、それぞれ13個ずつ作り、最後に1本の配列につなげる」という処理を1行で書ける。できあがるのは、1枚につき「一意のID」「スート」「ランク」「画像ファイルの場所」「ペア成立済みかどうか」を持つデータが52個並んだ配列である。

この配列をシャッフルしたうえで、先頭から順に52個のボタンを作って盤面に置く。配列の並び順が、そのまま画面の並び順になる。各ボタンには「配列の何番目か」という番号を持たせておき、押されたときにその番号で配列を引く。画面上の部品とデータとを結ぶ紐は、この番号1本だけである。

要素技術2:CSS Gridによる盤面の配置

CSS Gridによる盤面の配置と画面幅に応じた折り返し
AI生成コンテンツ / AI-generated content
52枚のカードを4行13列に並べる。この配置を作るのに、カード1枚ずつの座標を計算する必要はない。CSS(Cascading Style Sheets、見た目を指定する言語)には CSS Grid という仕組みがあり、「この箱の中身を13列の格子に並べよ」と1行書くだけで済む。

実際の指定は「grid-template-columns: repeat(13, minmax(55px, 1fr))」である。repeat(13, …) は「同じ指定を13回繰り返す」という意味で、13列であることを示す。minmax(55px, 1fr) は1列の幅は最低55ピクセル、余った幅は13列で等分するという意味である。1fr の fr は fraction(割合)の略で、「残りの場所を分け合う」ことを表す。

この2行だけで、52枚を入れれば勝手に13枚ずつ4行に折り返してくれる。カード1枚の幅も、画面が広ければ広く、狭ければ狭く、自動で決まる。座標の計算も、行を折り返す処理も、画面幅が変わったときの再計算も、いっさい書かない。Canvasでこれをやるなら、画面幅を取得し、1枚あたりの幅を割り算で求め、52回の繰り返しの中で行と列を計算して描く、という処理を書くことになる。

スマートフォンでは13列だとカードが細くなりすぎる。そこで @media という指定を使い、画面幅が900ピクセル以下なら8列、560ピクセル以下なら6列に切り替える。列数を書き替えるだけで、あとはCSSが並べ直す。「4.3 ブロック崩し」「4.4 落ちものブロック」でもレスポンシブな画面作りを扱ったが、部品を並べる方式ではその手間がさらに軽くなる。

カード1枚の形も、CSSに任せる。トランプは縦長なので、幅に対して高さがおよそ1.43倍になる。これを「aspect-ratio: 1 / 1.43」と指定しておけば、幅が決まれば高さは自動で決まる。この指定は「4.3 ブロック崩し」でも使った。数値の 1.43 は、CSSの変数(カスタムプロパティ)として1か所にまとめてある。使っているカード画像の縦横比が違えば、この1行を直すだけで全52枚に反映される。

カスタムプロパティは、CSSに用意された変数の仕組みである。「--gold: #f0c75e」のように名前と値を書いておき、使う場所では「color: var(--gold)」と書く。本プログラムでは、文字色、盤面の緑、金色、危険を示す赤、カードの縦横比の5つをこの形でまとめてある。配色を変えたいとき、CSS全体を検索して回る必要がない。「4.4」で述べた調整する数値を1か所に集めておくという作法を、CSSの側でも実践しているにすぎない。
盤面の配置に使ったCSSの指定
指定意味
display: gridこの箱の中身を格子状に並べる
grid-template-columns列の数と、1列あたりの幅の決め方
repeat(13, …)同じ指定を13回繰り返す(13列にする)
minmax(55px, 1fr)最低55ピクセル、余った幅は等分する
gapカードとカードの間隔
aspect-ratio幅に対する高さの比。トランプは1対1.43
@media (max-width: 900px)画面幅900ピクセル以下のときだけ適用する指定
--名前 と var(--名前)CSSの変数。値を1か所にまとめる
もう1つ、本プログラムで多用しているのが clamp() である。「font-size: clamp(1.7rem, 4vw, 3.1rem)」と書くと、最小1.7、望ましくは画面幅の4パーセント、最大3.1という3つの値を一度に指定できる。画面が狭ければ1.7で下げ止まり、広ければ3.1で頭打ちになる。これがないと、@media で画面幅ごとに文字の大きさを何段階も書き分けることになる。カードの間隔、余白、角の丸みなど、画面幅に応じて変えたい値のすべてに使っている。

なお、ステータス表示のツールバーには「position: sticky; top: 0」を指定してある。画面をスクロールしても上端に貼り付いたまま残るという指定である。カードが1画面に収まらない縦長の画面でも、手数と経過時間が常に見えている。

要素技術3:CSSの3D変換によるカードめくり

2枚の面を背中合わせにして回転させるカードめくりの仕組み
AI生成コンテンツ / AI-generated content
神経衰弱の見せ場は、カードがくるりと裏返る瞬間である。この動きを、JavaScriptを1行も書かずにCSSだけで作る。使うのは、CSSが持っている3D変換の機能である。

考え方は、実物のトランプとまったく同じである。1枚のカードには表と裏があり、2つの面が背中合わせに貼り合わさっている。だからHTMLでも、カード1枚のボタンの中に「裏面」と「表面」の2つの面を入れ、この2枚を背中合わせに重ねる。そして、2枚をまとめて縦軸のまわりに180度回転させれば、裏が向こうへ行き、表がこちらを向く。

この「背中合わせに重ねる」「まとめて回す」「裏側は見せない」という3つを、CSSの4つの指定で実現する。
カードめくりに使うCSSの指定
指定付ける場所意味
perspectiveカード(button)奥行きの見え方を決める。値が小さいほど手前に迫って見える
transform-style: preserve-3d2つの面を包む箱中身を平面につぶさず、立体のまま扱う
transform: rotateY(180deg)2つの面を包む箱縦軸のまわりに180度回す
backface-visibility: hidden裏面・表面のそれぞれその面の裏側が向いているときは見せない
transition: transform .34s2つの面を包む箱回転を0.34秒かけて滑らかに行う
この4つのうち、最も分かりにくいのが backface-visibility: hidden である。日本語にすると「背面を見せない」となる。紙に印刷した絵を思い浮かべてほしい。紙を裏返すと、絵は裏側からは見えない。CSSの世界では、この指定をしないと裏返しても絵が鏡文字で透けて見えてしまう。hiddenを指定することで、初めて実物の紙と同じ振る舞いになる。

表面のほうには、あらかじめ「transform: rotateY(180deg)」を掛けて最初から裏返した状態で置いておく。こうしておくと、外側の箱が180度回ったとき、表面は 180 + 180 で360度、つまり正面を向く。裏面は0 + 180 で180度、つまり向こうを向いて見えなくなる。実物のカードで、表の絵が裏返しに貼ってあるのと同じ理屈である。

perspective は、遠近感の強さを決める指定である。値は「見ている人の目が画面からどれくらい離れているか」をピクセルで表す。本プログラムでは700ピクセルとした。この値を大きくすると、遠くから望遠レンズで見たような、平べったい回り方になる。小さくすると、顔を近づけて見たような、手前が大きく歪む回り方になる。実物のカードらしく見える値は、実際に動かして決めるしかない

そして、めくる動作を起こすのは transition である。「transition: transform .34s cubic-bezier(.2,.75,.3,1)」と書いておくと、transformの値が変わったとき、0.34秒かけて少しずつ変化する。プログラムがやることは、カードの状態を「裏向き」から「表向き」に切り替えるだけである。あとはブラウザが、途中の角度を自動で計算して描いてくれる。1度ずつ回すような処理を書く必要はない。

末尾の cubic-bezier(.2,.75,.3,1) は、変化の速さの配分である。単純に等速で回すと機械的に見える。この指定は「最初は速く、終わりに向かって減速する」という配分を表しており、実物のカードを指ではじいたときの動きに近づく。4つの数値は、変化の度合いを描いた曲線の形を決めるものである。細かく理解する必要はなく、ease や ease-out といった名前付きの指定でも十分である。

アニメーションについては、もう1つ配慮すべきことがある。画面の動きで気分が悪くなる人がいるという事実である。前庭障害や片頭痛のある人にとって、画面上の回転やスライドは体調の悪化につながる。WindowsやmacOS、スマートフォンには「視差効果を減らす」「アニメーションを減らす」といった設定があり、CSSからはこの設定を prefers-reduced-motion で読み取れる。

本プログラムでは、この設定が有効なときにアニメーションの時間を0.01ミリ秒まで縮める指定を入れてある。カードは回転せず、瞬時に切り替わる。ゲームの遊び方は何も変わらない。1つのCSSブロックを書き足すだけで、遊べる人の範囲が広がる。仕様書には「アニメーションを無効にするOS設定がある場合は、その設定を尊重する」と書いておけばよい。

なお、ペア成立済みのカードにも同じ回転が掛かるようにしてある。裏向きに戻る指示だけを取り消せば、表向きのまま固定される。さらに金色の枠と淡い光を重ね、不透明度をわずかに下げて「もう選べないカード」であることを見た目で伝える。裏向き、表向き、ペア成立済みの3状態を色や枠で区別するのは、仕様書に明記しておくべき項目である。書かないと、成立済みのカードが表向きのままというだけの表示になり、どれを選べるのか分からなくなる。

カードの裏面は、画像を使わずCSSだけで描いている。斜め45度の線を2方向に重ねた市松模様を作り、その上に濃紺の円と金色のひし形を置いた。「linear-gradient」という、本来は色の階調を作る指定を使うと、階調の途中を急に切り替えることで縞模様や格子模様が描ける。画像ファイルが1つ減り、配布ファイルも軽くなる。

要素技術4:data-属性による状態の持ち方

data-属性による状態の持ち方と、CSSによる見た目の切り替え
AI生成コンテンツ / AI-generated content
52枚のカードには、それぞれ「裏向き」「表向き」「ペア成立済み」という状態がある。この状態を、どこに、どのように持たせるか。ここに1つ工夫がある。

素直に考えれば、JavaScriptの配列に持たせればよい。実際、本プログラムも配列の側に「ペア成立済みかどうか」を持っている。しかし、それだけだと見た目を変えるために毎回JavaScriptを書くことになる。カードが表になったら影を変え、枠を変え、回転させ、不透明度を変える。この4つを、めくるたび、戻すたび、成立するたびにJavaScriptから指示していくと、指示の書き忘れや順序の誤りが混ざり込む。

そこで本プログラムは、状態をHTMLの部品そのものに書き込む。HTMLには「data-」で始まる名前の属性を自由に付けてよいという決まりがあり、これを data-属性(カスタムデータ属性)と呼ぶ。本プログラムでは、カード1枚につき次の3つを付けている。
カードに付けたdata-属性
属性意味
data-index0〜51配列の何番目のカードか
data-facedown / up裏向きか、表向きか
data-matchedfalse / trueペアが成立したかどうか
JavaScriptからは「button.dataset.face = 'up'」と書くだけで値を変えられる。dataset は、data- で始まる属性をまとめて扱うための入口である。data-face なら dataset.face、data-matched なら dataset.matched と、data- を取り除いた名前でそのまま読み書きできる。

面白いのはここからである。CSSの側では「[data-face="up"] .card-inner { transform: rotateY(180deg); }」と書いておく。日本語に直すと「data-face の値が up であるカードの中身を、180度回せ」という意味である。同じように、data-matched が true のカードには金色の枠を付ける。JavaScriptがやることは、属性の値を1つ書き換えることだけになる。回転も、枠も、不透明度も、CSSが勝手に追随する。

この分業には名前がある。データと見せ方を分けるという考え方である。「4.4 落ちものブロック」で、盤面の配列には色ではなくブロックの種類を入れ、描画のときに色を引くという話をした。あれと同じことを、HTMLとCSSの間でやっている。プログラムは「いまどういう状態か」だけを宣言し、「その状態をどう見せるか」はCSSが受け持つ。配色や演出を変えたくなったとき、JavaScriptには一切手を触れない。

data-属性のもう1つの利点は、ブラウザの開発者ツールで画面を見ながら状態を確認できることである。開発者ツールでカードの要素を選ぶと、data-face や data-matched の値がそのまま表示される。JavaScriptの変数の中身を見るには、わざわざ値を出力する処理を書き足す必要があるが、data-属性なら画面上の部品を選ぶだけでよい。不具合を追うときに効いてくる。

ただし、data-属性に何でも入れてよいわけではない。値は必ず文字列になる。true と書いても、取り出したときは真偽値ではなく "true" という4文字の文字列である。data-index も "7" という文字列なので、配列を引く前に Number() で数値へ変換している。この変換を忘れると、配列の7番目ではなく「7」という名前の項目を探しに行って失敗する。JavaScriptは型に寛容な言語なので、この種の取り違えはエラーにならず、静かに誤った結果を返す。

また、ゲームの勝敗を決める情報をdata-属性だけに持たせてはいけない。data-属性はHTMLに書かれているので、開発者ツールから誰でも書き換えられる。本プログラムでは、ペア成立の判定はJavaScriptの配列に入ったランクどうしを比べて行い、data-matched はあくまで見た目のためだけに使っている。1人用のゲームなので実害はないが、対戦や得点のあるゲームでは、画面に置いた情報と、判定に使う情報を分けておく必要がある。

要素技術5:setTimeoutによる遅延と入力ロック

2枚目をめくってから裏返るまでの時間と、入力を受け付けない区間
AI生成コンテンツ / AI-generated content
ランクの違う2枚をめくったとき、すぐに裏返してしまってはプレイヤーが内容を確認できない。かといって、いつまでも表向きのままでは次に進めない。そこで約1秒間表示してから裏向きに戻す。この「1秒待ってから何かをする」という処理に使うのが setTimeout である。

setTimeout は、「この処理を、いまから何ミリ秒後に実行せよ」とブラウザに予約する命令である。setTimeout(処理, 1000) と書けば、1000ミリ秒すなわち1秒後にその処理が動く。「4.4 落ちものブロック」で使った setInterval が「何ミリ秒ごとに繰り返せ」であるのに対し、setTimeout は1回だけである。

ここで初心者が驚くのは、setTimeout を呼んだ時点でプログラムは止まらないという点である。「1秒待つ」と書いてあるのに、プログラムはその行を通り過ぎて先へ進んでしまう。1秒後に動くのは、setTimeout に預けた処理だけである。この、待っている間もほかのことが動き続ける仕組みを 非同期 と呼ぶ。JavaScriptにはプログラムを本当に止める命令がないので、時間の絡む処理はすべてこの形になる。

非同期であることが、そのまま不具合の種になる。2枚目をめくってから裏返るまでの1秒の間、プログラムは何も待っていない。プレイヤーが3枚目、4枚目を続けてクリックすれば、そのクリックは受け付けられてしまう。結果、3枚以上が同時に表向きになり、どれとどれを比べているのか分からない状態に陥る。マウスを素早く連打したときや、スマートフォンで指が滑ったときに起きる。

対策は、判定中であることを示す旗を立てることである。本プログラムは locked という真偽値を持っており、2枚目をめくった瞬間に true にする。カードをクリックしたときの処理は、いちばん最初にこの旗を調べ、立っていたら何もせずに戻る。1秒後、2枚を裏返し終えたところで旗を false に戻す。ペアが成立した場合は待つ必要がないので、その場で戻す。

実際には、旗のほかにも調べることがある。本プログラムがカードのクリックを受け付ける条件は、次の4つがすべて満たされたときだけである。
カードのクリックを受け付ける条件
条件満たされないと起きること
ゲームの状態が「プレイ中」である開始前や一時停止中、クリア後にカードがめくれてしまう
判定中の旗が立っていない3枚以上が同時に表向きになる
そのカードがペア成立済みでない成立済みのカードを2枚目として選べてしまう
そのカードが表向きでない同じカードを2回クリックしてペアが成立してしまう
4つ目の条件は、とくに見落としやすい。1枚目にめくったカードをもう一度クリックすると、それを2枚目とみなしてランクを比べ、当然ランクは同じなのでペアが成立してしまう。26回クリックするだけでクリアできる、ゲームとして成り立たない状態になる。仕様書には「同じカードを2回選択できないようにする」と1行書いておく必要がある。ルールとして当たり前すぎることほど、書き落とされやすい。

setTimeout には、もう1つ気を付けることがある。予約した処理を取り消す手段を持っておくということである。2枚が表向きの状態のまま「最初から」ボタンを押すと、盤面は作り直されるが、1秒後に動く予約は生き残っている。その予約が動くと、すでに存在しない古いカードを裏返そうとして誤作動する。

setTimeout は呼び出したときに予約番号を返す。この番号を変数に控えておき、ゲームを作り直すときに clearTimeout(番号) を呼べば予約を取り消せる。同じことが setInterval と clearInterval にも言える。本プログラムでは、経過時間の表示更新に setInterval を使っているので、ゲーム開始時と終了時に必ず clearInterval を呼んでいる。「4.4」で述べたループの多重起動を防ぐのと同じ話である。止め忘れると、時間が2倍の速さで進むといった、原因の分かりにくい不具合になる。

経過時間そのものの数え方にも触れておく。1秒ごとに変数を1つ増やす方式は採らない。開始した時刻を覚えておき、表示するたびに現在時刻との差を計算する。setInterval の呼び出し間隔はブラウザの都合で前後するので、数え上げる方式では時間がずれていく。本プログラムは表示の更新だけを0.25秒ごとに行い、表示する値は毎回計算し直している。一時停止したときは、それまでの経過時間を別の変数に退避し、再開時に現在時刻を基準に取り直す。これで一時停止中は時間が進まない。

要素技術6:キーボードと読み上げへの対応

Tabキーによるフォーカス移動と、読み上げソフトへの情報の伝達
AI生成コンテンツ / AI-generated content
アクセシビリティ(accessibility)とは、目が見えにくい人、手が不自由な人、マウスを使わない人を含めた、より多くの人がそのソフトウェアを使えるようにすることをいう。第4章でここまで作ってきたゲームは、いずれもこの点で見劣りがした。Canvasに描いた画面は、読み上げソフトから見ると単なる1枚の絵にすぎない。何が描いてあるかを伝える手段がない。

HTML要素で盤面を作ると、この状況が一変する。カードがボタンである以上、ブラウザはそれを操作できる部品として扱う。Tabキーを押せば、1枚目、2枚目、3枚目と順に移動できる。EnterキーやSpaceキーを押せば、クリックしたのと同じ処理が動く。ここまでは、プログラムを1行も書かずに手に入る。

書き足すのは、いま選ばれているカードを目に見えるようにする指定だけである。「.card:focus-visible { outline: 3px solid var(--gold); }」と書けば、キーボードで選んだカードに金色の枠が付く。focus という指定ではなく focus-visible を使っているのが要点で、こちらはキーボードで操作したときだけ枠を出す。マウスでクリックしたときには出ない。マウス利用者には邪魔になるが、キーボード利用者には枠がないと自分の居場所が分からない。両者を両立させるための指定である。

次に、読み上げへの対応である。カードのボタンには aria-label という属性を付ける。読み上げソフトは、この属性の値をそのまま読み上げる。本プログラムは、裏向きのときは「裏向きのカード」、めくったあとは「ハートの7」と、状態に応じて値を書き換えている。裏向きのカードに正解を書いておいてはゲームにならないので、この書き換えは必須である。

ARIA(Accessible Rich Internet Applications)は、HTMLだけでは伝えきれない情報を支援技術へ届けるための属性の集まりである。W3Cが仕様を定めており、名前はすべて aria- で始まる。本プログラムで使っているのは次の4つである。
使用しているARIA関連の属性
属性付ける場所役割
aria-labelカード、ボタン読み上げる文言を指定する。状態に応じて書き換える
role="status"メッセージ欄この場所は状況を伝える欄であると宣言する
aria-live="polite"メッセージ欄中身が変わったら、区切りのよいところで読み上げる
aria-hiddenカードの裏面、一時停止の覆い装飾目的なので読み上げ対象から外す
このうち aria-live は、少し変わった働きをする。読み上げソフトは通常、利用者が選んだ場所だけを読む。しかし「ペア成立!」というメッセージは、利用者が選んでいない場所に現れる。aria-live を付けた場所は中身が変わった瞬間に読み上げられる。値の polite は「いま読んでいる内容を中断せず、区切りのよいところで読め」という意味である。assertive にすると即座に割り込むが、多用すると利用者を混乱させる。ゲームの進行を伝える程度なら polite で十分である。

逆に aria-hidden="true" は、読み上げ対象から外す指定である。カードの裏面は市松模様を描いただけの装飾であり、読み上げても意味がない。一時停止中の覆いも、表示していないときは読ませない。画面に見えているものすべてを読み上げればよいわけではない。装飾を読み上げると、必要な情報が埋もれてしまう。

ここまでの対応で、画面をまったく見ずに神経衰弱を遊ぶことができる。Tabキーで盤面をたどり、Enterでめくり、読み上げでカードの内容と結果を聞き、頭の中で位置を記憶する。むしろ画面が見えない状態のほうが、記憶ゲームとしては純粋かもしれない。仕様書のテスト観点には「キーボードだけでもすべてのカードを選択し、ゲームを完了できるようにする」と書き、実際に試すよう指示しておく。

タッチ操作についても一言添えておく。カードには「touch-action: manipulation」を指定してある。これを書かないと、素早く2回タップしたときにブラウザが画面を拡大してしまう。神経衰弱は連続してタップするゲームなので、この誤動作が頻発する。「4.4 落ちものブロック」でも触れたが、操作をゲームに割り当てるときは、ブラウザが元々持っている動作を打ち消す作業がついてくる

最後に、開始画面とクリア画面の切り替えについて。本プログラムは、画面全体を覆う半透明の層を2枚用意し、HTMLの hidden 属性を付けたり外したりして表示を切り替えている。hidden は「この要素は今は関係ない」という意味の標準の属性で、見えなくなると同時に、読み上げの対象からも外れ、Tabキーでも到達しなくなる。CSSで「display: none」と書いても同じ結果になるが、hidden 属性のほうが意図が明確であり、CSSが読み込まれなかった場合にも効く。

要素技術7:カード画像の検証とフォールバック

カード画像が読み込めないときの代替表示と、起動前の検証
AI生成コンテンツ / AI-generated content
これまでのゲームと違い、神経衰弱は外部のファイルに依存する。52枚のカード画像である。落ちものブロックのブロックはCSSの色指定だけで描けたが、トランプの絵柄はそうはいかない。画像ファイルが1つでも足りなければ、そのカードだけ真っ白になる。

外部ファイルに依存するプログラムでは、ファイルが揃っていないという前提で書く必要がある。本プログラムは、2段構えで備えている。1つはゲーム開始前の検証、もう1つは読み込み失敗時の代替表示である。

検証のほうから説明する。ゲームを始める前に、52枚のカード定義そのものを調べる。調べるのは3点である。
ゲーム開始前の検証項目
調べること見つかる誤り
データが52件あるか繰り返しの回数を間違えている
IDが52種類あるか(重複がないか)同じIDを2枚に付けている
各ランクが4枚ずつあるかスートまたはランクの組み合わせに漏れがある
2つ目の「IDの重複」は、集合という仕組みを使うと簡単に調べられる。JavaScriptの Set は、同じ値を2つ以上持てない入れ物である。52個のIDを Set に入れ、入れ終わったあとの個数が52でなければ、どこかに重複がある。1つ1つ総当たりで比べる必要はない。

検証に引っかかったときは、その場でエラーを発生させ、「ゲーム開始」ボタンを押せない状態にして、理由を画面に表示する。誤ったまま始めてしまうより、始めさせないほうがよい。52枚のうち1枚が欠けた状態でゲームを始めると、最後の1組がどうしても揃わないという、原因の見えにくい行き詰まりになる。

もう1つの備えが、画像そのものが読み込めなかったときの代替表示である。HTMLの img 要素は、画像の読み込みに失敗すると error というできごとを発生させる。本プログラムは52枚すべての img にこのできごとの受け手を登録しておき、失敗したら画像を隠して、代わりに「♥7」のようにスートの記号とランクの文字を表示する。ゲームは続行でき、どのカードかも分かる。あわせて、読み込めなかったファイル名を画面のメッセージ欄に出す。

この「うまくいかないときは、劣るが動く方法に切り替える」という考え方を フォールバック(fallback)と呼ぶ。プログラム全体が止まるのと、絵が文字に変わるのとでは、利用者にとっての損失がまるで違う。何かが欠けたときに何が起きるかを決めておくのは、配布するプログラムの必須の作法である。

画像の表示のしかたにも一手間かける。「object-fit: contain」を指定すると、縦横比を保ったまま、枠の中に全体が収まるように縮小される。指定しないと画像が枠いっぱいに引き伸ばされ、絵柄が縦長や横長に歪む。cover という値もあり、こちらは枠を埋めるかわりに画像の端をはみ出させて切り落とす。トランプは絵柄の端が切れると困るので contain を選ぶ。

仕様書には、画像に関する取り決めをまとめて書いておく。実装前に保存場所とファイル名の規則を確認すること、ファイル名からスートとランクの対応を安全に判定できない場合は推測せず質問すること、画像ファイル自体をユーザーの許可なく加工・改名・移動・削除しないこと、インターネット上の画像を参照せず相対パスで参照すること、配布時に必要な画像を漏れなく同梱すること。とくに「推測せず質問する」の1行は重要で、これがないとCodexが独自にファイル名の規則を決めつけて、対応の狂った盤面ができあがる。

最後に、想定外のエラーへの備えについて。本プログラムは、ブラウザ全体の未処理エラーを受け取る仕組み(window の error)を用意している。エラーを受け取ったら、タイマーを止め、状態を「エラー」に変え、内容を画面に表示する。何も表示しないと、画面が固まったように見えて、利用者には原因がまったく分からない。表示したエラー内容は、不具合の報告を受けたときの手がかりにもなる。

ゲーム進行と成績保存の仕組み

ここまでの7つが、神経衰弱ゲームで新しく登場した技術である。以下、これまでの記事で扱った技術がこのゲームでどう使われているかを、まとめて紹介する。すでに知っている読者は読み飛ばしてよい。

まず、カードのシャッフルである。「4.4 落ちものブロック」で、ブロックの出現順を混ぜるのに使ったフィッシャー–イェーツのシャッフルを、そのまま52枚のカードに適用する。配列の後ろの要素から順に、それ自身を含む前方のどれかと入れ替えていくという手順で、どの並び順も等しい確率で現れる。「適当に何回か入れ替える」といった素朴な方法では、特定の並びが出やすくなる。乱数そのものについては「3.2 パスワード生成ツール」で触れた。

JavaScriptでの入れ替えは「[result[i], result[j]] = [result[j], result[i]]」と1行で書ける。左辺と右辺の両方を配列の形にすると、対応する位置どうしがまとめて代入される。分割代入と呼ぶ書き方で、値を一時的に退避させる変数が要らない。

次に、ゲームの状態である。このゲームは「開始前(ready)」「プレイ中(playing)」「一時停止(paused)」「クリア(complete)」「エラー(error)」の5つの状態のいずれかにある。この状態を1つの変数で持ち、操作を受け付ける前に必ず状態を調べる。「4.4」で紹介した有限状態機械の考え方である。画面の切り替えを伴うプログラムでは、状態を明示的に持たないと「クリア画面でカードをクリックしたら手数が増えた」といった不具合が入り込む。
ゲームの5つの状態
状態画面カード操作時間
開始前開始画面と過去の成績受け付けない止まっている
プレイ中盤面受け付ける1枚目をめくった後は進む
一時停止盤面を覆いで隠す受け付けない止まっている
クリア結果画面受け付けない止まっている
エラーエラー内容を表示受け付けない止まっている
成績はブラウザの localStorage に保存する。この仕組みは「3.4 簡易メモ帳」で詳しく取り上げた。Webページの発行元ごとにデータを保存でき、サーバーへは送られず、ブラウザを閉じても残る。日時・手数・経過時間を1件にまとめ、新しい順に並べた配列を文字列(JSON)に変換して書き込む。31件目からは古いものを捨て、常に30件に保つ。

読み込むときは、取り出した内容が本当に配列かどうかを確かめる。localStorageの中身は利用者が書き換えられるうえ、以前の版のプログラムが別の形式で保存している可能性もある。想定と違う形のデータをそのまま使うと、成績一覧の表示で処理が止まり、ゲーム全体が動かなくなる。読み書きはいずれも try-catch で囲み、失敗しても理由を画面に出すだけでゲーム本体は続行する。プライベートブラウジングなど、localStorageを使えない環境も存在するためである。

日時の表示には Intl.DateTimeFormat を使う。「2026年(令和8年)08月03日 14時30分」といった書式を、自前で組み立てずに標準の命令へ任せる。月や日の2桁揃え、年月日の並び順、和暦との対応といった、地域ごとの細かい取り決めがすべて含まれている。自分で書式を組み立てるより短く、間違いが少ない。「4.4」でも同じ命令を使った。

最少手数と最短時間は、保存した30件から Math.min で求める。どちらを優先するかは決めない。手数を減らそうとすればじっくり考えて時間がかかり、時間を縮めようとすれば当てずっぽうが増えて手数が伸びる。2つは相反するので、片方の物差しで順位を付けるとゲームが窮屈になる。それぞれを独立した記録として表示するにとどめてある。

細かい点では、画面上の部品を取得する処理をまとめてある。「board」「pairs」「moves」……といったIDの一覧を配列に書いておき、Object.fromEntries を使って一度に取り出す。IDを1つずつ書いて変数に代入していくと、部品が増えるたびに同じ形の行が増えていく。この書き方なら、追加するのは配列に名前を1つ足すだけである。

テストのための仕組みも入れてある。「4.3 ブロック崩し」で扱ったテスト容易性の話である。本プログラムは、カード定義を作る処理、時間を書式化する処理、現在の状態を返す処理の3つを外から呼べるようにしてあり、Codexはこれを使ってプレイテストを行える。Object.freeze で包んで書き換えを禁じてあるので、外部から状態を操作されることはない。配布版ではこの仕組みを無効化または削除するよう、仕様書に書いておく。

innerHTMLは使ってよいのか

自分で作った文字列と、外から来た文字列の違い
AI生成コンテンツ / AI-generated content
4.4 落ち物パズルゲーム」の記事で、成績表の作り方について次のように書いた。文字列でHTMLを作る書き方は、入力内容によって意図しないタグが混ざる危険がある。だから成績表は行とセルを1つずつ作る命令で組んだ、と。

ところが本項のプログラムは、その「危険がある」と書いた書き方を堂々と使っている。52枚のカードのHTMLを1本の長い文字列として組み立て、innerHTML という命令で盤面に流し込んでいる。成績一覧の表も同じである。矛盾しているように見えるので、順を追って説明する。

まず、何が危険なのかをはっきりさせる。危険の正体は クロスサイト・スクリプティング(XSS)と呼ばれる攻撃である。innerHTML に渡した文字列は、ただの文字ではなくHTMLとして解釈される。もしその文字列の中に「<script>」で始まる部分が紛れ込んでいれば、ブラウザはそれをプログラムとみなして実行してしまう。掲示板に投稿された文章をそのまま innerHTML で表示するようなプログラムは、投稿欄にプログラムを書き込まれると、それを閲覧した全員の環境でそのプログラムが動く。

つまり、危険なのは innerHTML という命令そのものではなく、中身に何を混ぜるかである。判断の分かれ目は1つしかない。
innerHTMLに渡してよい材料・いけない材料
材料の出どころinnerHTML
プログラムの中に書いた固定の文字列「♠」「クラブ」「裏向きのカード」使ってよい
プログラムが計算した数値手数、経過時間、ペア数使ってよい
利用者が入力した文字名前、コメント、検索語使ってはいけない
ネットワークから受け取った文字APIの応答、他人の投稿使ってはいけない
URLに含まれる文字「?name=…」の部分使ってはいけない
localStorageから読んだ文字過去の成績(書き換え可能)原則として使わない
本項のプログラムが innerHTML に渡している材料は、すべて1行目の「プログラムの中に書いた固定の文字列」と2行目の「計算した数値」だけである。スートの名前も、ランクの文字も、画像ファイル名の組み立て方も、すべてプログラムの中に書いてある。利用者が入力する欄はどこにもない。ネットワーク通信もしない。だからこのプログラムに限っては、innerHTML を使っても危険が生じない。

では、なぜ「4.4」ではわざわざ避けたのか。成績表に日時が入るためである。日時は Intl.DateTimeFormat が返す文字列なので実際には安全だが、成績はlocalStorageから読み込んでいるという点が引っかかる。localStorageの中身は、開発者ツールを使えば誰でも書き換えられる。将来「プレイヤー名を記録する」といった機能を足したとき、その名前が何の検査もなく innerHTML へ流れ込む経路ができあがってしまう。

本項のプログラムは、この点でやや妥協している。成績一覧の表を innerHTML で組み立てており、将来プレイヤー名を追加すれば、そこが弱点になる。1人用でネットワーク通信のないゲームなので実害は考えにくいが、機能を足すときには書き方を改める必要がある。この記事を読んで自分のゲームを改造する読者には、利用者が入力した文字を扱い始めた時点で innerHTML をやめると決めておくことをすすめる。

やめるとは、具体的には createElement と textContent を使う書き方に切り替えることである。createElement で空の部品を作り、textContent に文字を入れる。textContent に入れた文字は、どんな内容でもHTMLとして解釈されない。「<script>」と書けば、画面にはその8文字がそのまま表示されるだけである。行数は増えるが、材料の出どころを気にせずに済む。

まとめると、innerHTML は禁止の命令ではなく、条件付きの命令である。条件とは「渡す文字列を、最後の1文字まで自分が把握していること」である。52枚のカードのように、内容が完全に決まっていて量が多いものを一度に作る用途では、innerHTML のほうが短く読みやすい。判断を先送りにせず、なぜこの場面では使ってよいのかを説明できる状態にしておくことが大切である。

なお、Codexに仕様書を渡すときには、この判断を仕様書側に書いておくとよい。何も書かないと、Codexは安全側に倒して createElement を使った長い処理を書いてくることもあれば、何も考えず innerHTML を使うこともある。どちらでもよいのではなく、理由とともに指定する。それが仕様書の役目である。

仕様書と実装の食い違い

仕様書を書いてCodexに渡せば、そのとおりのプログラムができあがる。多くの場合はそうなるが、いつもそうとは限らない。本項のプログラムにも、仕様書に書いた内容と実際にできあがったものとの間に、いくつかの食い違いがあった。隠さずに列挙しておく。食い違いの見つけ方と扱い方を知ることが、AI支援プログラミングでは仕様書を書くことと同じくらい重要だからである。
仕様書の記述と実装の食い違い
項目仕様書の記述実際の実装
成績の表示1画面に表示しきれない場合は自動スクロールして全体を表示する高さ230ピクセルの領域に収め、利用者が手で操作してスクロールする
カードの配置スマートフォンでは複数行に折り返す900ピクセル以下で8列、560ピクセル以下で6列と、2段階で切り替える
エラーの捕捉JavaScript実行中にシステムエラーが発生した場合は安全に終了するwindow の error は受けるが、非同期処理のエラー(unhandledrejection)は受けていない
画像の検証カード画像の重複、欠落、スート・ランクの不正をゲーム開始前に検証するカード定義は検証するが、画像ファイルの存在確認は行っていない
裏面画像裏面画像を用意している場合はその画像を使用する常にCSSで描画し、裏面画像を使う経路がない
1つ目の「自動スクロール」は、こちらの書き方が悪かった例である。「自動スクロール」という言葉は、勝手に流れていく表示という意味にも、必要に応じてスクロールできる表示という意味にも取れる。Codexは後者と解釈した。仕様書には「成績が31件以上ある場合でも、利用者の操作で全件を閲覧できるようにする」のように、結果として何ができればよいかを書くべきだった。手段を表す言葉は解釈がぶれる。

2つ目の「複数行に折り返す」は、書き方が粗すぎた例である。何列にするかを書かなかったので、Codexが8列と6列を選んだ。結果は妥当だが、これは偶然である。数値を指定していない箇所は、Codexが自分で決める。決められて困る数値は、必ず仕様書に書く。

3つ目の「非同期処理のエラー」は、仕様書が抽象的だった例である。「システムエラー」という言葉だけでは、何をどこまで捕まえればよいか決まらない。JavaScriptには、通常のエラーとは別に、非同期処理の中で起きたエラーという系統がある。「4.4」の仕様書では error と unhandledrejection の2つを明示していたが、本項では書き漏らした。前に書いた仕様書から、書くべき項目を写し取るという作業を怠ると、こういう抜けが生じる。

4つ目と5つ目は、仕様書が実物より先に書かれたことによる食い違いである。画像ファイルは利用者が後から配置するので、書いた時点では存在しなかった。裏面画像も「用意している場合は」という条件付きで書いたが、実際には用意されなかったため、その経路は作られなかった。この2つは実害がなく、直す必要もない。仕様書に書いたことがすべて実装される必要はない

重要なのは、食い違いを放置しないことである。放置してよいものと、直すべきものを見分け、直すべきものは指摘して直させる。そして、直したあとの内容を仕様書(PROMPT.md)へ書き戻す。仕様書とプログラムが食い違ったまま配布すると、次にそのプログラムを改造するときに、どちらが正しいのか分からなくなる。「2.2 配布ファイルを作る」で述べたとおり、プログラム、README、仕様書の3つは常に同じ内容を指していなければならない

食い違いを見つけるには、仕様書のテスト観点を1つずつ手で確かめるしかない。Codexは自分が書いたプログラムを自分でテストするが、実際に確認できなかった項目を「合格」と報告してくることがある。本項の仕様書にも「実際に確認できなかった項目を『合格』と推測して報告しないこと」「テスト結果を、実施内容、期待結果、実際の結果、合否が分かる形式で報告すること」の2行を入れてある。26組すべてを揃えるには何百回もクリックする必要があり、Codexが最後まで確認しきれない項目が必ず残る。未確認は未確認として報告させ、そこは人間が確かめる

神経衰弱ゲームのプログラム仕様書

以上を踏まえて、神経衰弱ゲーム「memoryGame.html」のプログラム仕様書を示す。これが Codex に渡すプロンプトそのものになる。

「4.4 落ちものブロック」の仕様書と比べると、ゲームのルールに関する記述が減り、入力制御と画像の扱いに関する記述が増えている。ルール自体は「同じランクの2枚を探す」だけで単純だが、そのかわり52枚の部品を正しく制御し、外部の画像ファイルと確実に対応づけることに手がかかる。ゲームの難しさと、プログラムの難しさは一致しない

とくに「# 入力制御」の節は、この仕様書で最も分量を割いた部分である。同じカードを2回選べないこと、成立済みのカードを選べないこと、一度に表向きにできるのは2枚までであること、判定中はほかのカードを受け付けないこと、素早く連続クリックしても3枚以上が表向きにならないこと。5つとも「当たり前」に見えるが、1つでも書き落とすとゲームが破綻する
プログラム仕様書(プロンプト)
# 神経衰弱ゲーム

# 目標 プレイヤーは、裏向きに並べられた52枚のトランプから2枚ずつカードをめくり、同じランクのペアを見つける。記憶を頼りに、できるだけ少ない手数と短い経過時間ですべてのカードを揃える。
# プログラム・ファイル名 memoryGame.html
# プロジェクト・フォルダ作成 - プログラム・ファイル名の拡張子を除いた主ファイル名と同じ名前のサブフォルダ「memoryGame」を作成し、以降の作業はサブフォルダで行う。 - すでにサブフォルダがあれば、そのサブフォルダに移動して以降の作業を進める。 - ユーザーが作成した既存ファイルや変更内容を、許可なく削除または上書きしない。 - サブフォルダを作成したらユーザーに知らせる。ユーザーは、トランプの画像データをサブフォルダ下の image フォルダに配置する。配置できたら作業を再開する。
# ゲーム・ルール ## 基本条件 - プレイヤーは1人とする。 - ジョーカーを除くトランプ52枚を使用する。 - カードは4スート(スペード、ハート、ダイヤ、クラブ)×13ランク(A、2〜10、J、Q、K)で構成する。 - スートが異なってもランクが同じであればペア成立とする。 - 例として、ハートの7とスペードの7はペアとして扱う。 - 同一ランクは4枚あるため、各ランクにつき2組、全体で26組のペアが成立する。 - 対戦相手、得点、制限時間、ライフ、ヒント、特殊カード、アイテムは設けない。
## ゲーム開始 - ゲーム開始画面には、過去の成績を表示する。成績が31件以上ある場合でも、利用者の操作で全件を閲覧できるようにする。 - 「ゲーム開始」ボタンを押すと52枚をランダムにシャッフルする。 - シャッフルしたカードをすべて裏向きで盤面に配置する。 - 毎回のゲーム開始時および再プレイ時に、カードの配置を再シャッフルする。 - 最初のカードを選択した時点で経過時間の計測を開始する。
## カード選択とペア判定 1)プレイヤーが未成立の裏向きカードを1枚選ぶと、そのカードを表向きにする。 2)続けて別の未成立の裏向きカードを1枚選び、表向きにする。 3)2枚目を表向きにした時点で手数を1増やし、2枚のランクを比較する。 4)ランクが同じ場合はペア成立とし、2枚を表向きのまま固定して選択不能にする。 5)ランクが異なる場合は、プレイヤーがカードを確認できるよう約1秒間表示した後、2枚とも裏向きへ戻す。 6)判定と表示処理が完了した後、次のカードを選択できるようにする。
## 入力制御 - 同じカードを2回選択できないようにする。 - すでに表向きのカード、ペア成立済みのカードは選択できないようにする。 - 一度に表向きにできる未成立カードは2枚までとする。 - 2枚目を開いてからペア判定が完了するまで、ほかのカードへの入力を受け付けない。 - カードを素早く連続クリックまたは連続タップしても、3枚以上が同時に表向きにならないようにする。 - ゲーム開始前、ゲーム終了後、一時停止中はカードを選択できないようにする。
## クリア条件 - 26組すべてのペアが成立した時点でゲームクリアとする。 - 最後のペアが成立した時点で経過時間の計測を停止する。 - ゲームオーバー条件および制限時間は設けない。 - プレイヤーが中断しない限り、クリアするまでゲームを続けられるようにする。
# 手数と経過時間 ## 手数 - 2枚目のカードを開くたびに1手として数える。 - ペアの成立、不成立にかかわらず手数を1増やす。 - 1枚だけ開いた状態では手数を増やさない。 - 現在の手数をゲーム中、常に画面へ表示する。
## 経過時間 - 制限時間は設けず、ゲーム開始後の経過時間を記録する。 - 最初のカードを選択するまでは「00:00」と表示する。 - 最初のカードを選択した時点から計測を開始する。 - 「分:秒」の形式で表示し、1時間以上の場合は「時:分:秒」の形式にする。 - 一時停止中は経過時間を増やさない。 - ブラウザの処理間隔に依存して時間がずれないよう、開始日時と一時停止時間を基準に算出する。 - ゲームクリア時の経過時間を成績として保存する。
# カード画像 - ユーザーが用意したジョーカーを除く52枚のカード画像を使用する。 - 実装前に画像ファイルの保存場所、ファイル名、拡張子、各画像とスート・ランクの対応を確認する。 - 対応関係をファイル名から安全に判定できない場合は推測せず、ユーザーに確認する。 - 画像ファイル自体は、ユーザーの許可なく加工、改名、移動、削除、上書きしない。 - カード画像の縦横比を維持し、カード枠内に全体が収まるように表示する。 - 52枚すべてに、プログラム内部で一意のID、スート、ランク、画像パスを割り当てる。 - 裏面はCSSで独自に描画するか、ユーザーが裏面画像を用意している場合はその画像を使用する。 - 表面画像が読み込めない場合は、代替表示としてスートとランクを文字で表示し、エラー内容を画面へ通知する。 - 画像はプロジェクトフォルダ内の相対パスで参照し、インターネット上の画像へアクセスしない。 - 配布時に必要なカード画像を漏れなく同梱する。
# 操作方法 ## マウス - 裏向きカードをクリック:カードを表向きにする。 - 「一時停止」ボタン:ゲームを一時停止または再開する。 - 「最初から」ボタン:確認後、現在のゲームを破棄して新しい配置で開始する。
## キーボード - Tabキー:カードまたは操作ボタンへフォーカスを移動する。 - EnterキーまたはSpaceキー:フォーカス中のカードまたはボタンを選択する。 - EscキーまたはPキー:一時停止/再開する。 - キーボードだけでもすべてのカードを選択し、ゲームを完了できるようにする。 - キーボード操作時は、フォーカス中のカードが明確に分かる表示を行う。
## 画面タッチ - 裏向きカードをタップ:カードを表向きにする。 - タップ可能領域を十分な大きさにし、隣のカードを誤って選びにくくする。 - タッチ操作中にブラウザ画面が意図せず拡大、選択、スクロールしないよう、必要な範囲だけ適切に制御する。
# 一時停止とリスタート - ゲーム中に「一時停止」ボタンを表示する。 - 一時停止中はカード盤面を隠し、カードの位置を見続けられないようにする。 - 一時停止中は経過時間を増やさず、カード選択を受け付けない。 - 再開すると、一時停止前のカード状態から継続する。 - 「最初から」ボタンを押した場合は、現在のゲームを破棄するか確認する。 - リスタート時はカードを再シャッフルし、手数、経過時間、成立ペア数を初期化する。 - リスタートによって中断したゲームはクリア成績として保存しない。 - リスタート時は、裏返し待ちのタイマーおよび経過時間のタイマーを必ず取り消してから開始する。
# 画面構成要素 ## ゲーム盤 - 52枚のカードを画面中央へ規則的に並べる。 - PCの標準配置は4行×13列を基本とする。 - 画面幅900ピクセル以下では8列、560ピクセル以下では6列に切り替える。 - カード同士が重ならず、表面の内容を識別できる大きさを確保する。 - 画面の縦横比やカード枚数によって1画面に収まらない場合は、安全に縦スクロールできるようにする。
## カード表示 - 裏向き、選択されて表向き、ペア成立済みの3状態を視覚的に区別する。 - カードをめくる際は短い反転アニメーションを表示する。 - ペア成立時は枠色や軽い強調表示で成功を伝える。 - ペア不成立時は、2枚を約1秒表示してから裏向きへ戻す。 - アニメーションを無効にするOS設定がある場合は、その設定を尊重する。
## ステータス表示 - 成立ペア数を「0 / 26」の形式で表示する。 - 現在の手数を表示する。 - 現在の経過時間を表示する。 - 一時停止中であることを明確に表示する。 - 画面をスクロールしてもステータス表示が見えるようにする。
# 画面構成 - 画面上部:タイトル、バージョン、著作権者、ライセンス。 - 画面上部または盤面上:成立ペア数、手数、経過時間。 - 画面中央:52枚のカードを配置したゲーム盤。 - 盤面周辺:「一時停止」「最初から」ボタン。 - 画面下部:マウス、キーボード、タッチの操作方法。 - ゲーム開始前に、ゲームの目的、ペア成立条件、手数の数え方を表示する。 - ゲーム開始前に「ゲーム開始」ボタンを表示する。 - ゲームクリア画面には、今回の手数、経過時間を表示する。 - ゲームクリア画面には「もう一度遊ぶ」「終了する」ボタンを表示する。 - 「終了する」ボタンでは、安全にゲームを停止して開始画面へ戻る。 - ブラウザのウィンドウやタブをプログラムから強制的に閉じない。 - PCとスマートフォンの両方で利用できるレスポンシブ構成にする。
# アクセシビリティ - カードおよび操作部分は、キーボードで到達および操作できる要素として実装する。 - カードには読み上げ用の説明を付け、裏向きのときは内容が分からない文言、表向きのときはスートとランクが分かる文言にする。 - ペア成立、不成立、エラーなどの状況変化を、読み上げ環境へ通知する領域に表示する。 - 装飾目的の要素は読み上げ対象から除外する。 - キーボード操作時のフォーカス位置を、明確な枠線などで表示する。
# データの記録 - ゲームクリア時の成績をブラウザのlocalStorageへ保存する。 - 成績には年月日時分、手数、経過時間を含める。 - 保存および表示する成績は直近30回分とする。 - 31回目以降は最も古い記録から削除する。 - 最少手数と最短時間を確認できるようにする。 - 手数と時間のどちらを優先するかは決めず、それぞれを独立したベスト記録として表示する。 - 保存先の名前には形式の版番号を付ける。 - 読み込んだ内容が想定した形式でない場合は、破棄して初期状態として扱う。 - localStorageを利用できない場合でもゲーム本体は続行できるようにし、保存できない理由を画面へ表示する。 - 外部サーバーへ成績を送信しない。 - 個人情報を記録しない。
# エラー処理 - JavaScript実行中にシステムエラーが発生した場合は、タイマーとアニメーションを停止し、ゲームを安全に終了する。 - 未処理のエラー(error)および未処理の非同期エラー(unhandledrejection)の両方を受け取る。 - エラー内容をプレイヤーが確認できる形で画面に表示する。 - 無効な入力やゲーム状態に合わない入力は受け付けない。 - 同じタイマーや判定処理が重複して実行されないようにする。 - カード画像の重複、欠落、スート・ランクの不正をゲーム開始前に検証する。 - 52枚すべてのカードIDが一意であることを検証する。 - 各ランクが4枚ずつ存在することを検証する。 - 検証に失敗した場合は「ゲーム開始」ボタンを押せない状態にし、理由を画面へ表示する。 - localStorageの読み書きで例外が発生しても、安全にゲームを継続する。
# テスト観点・合格条件 - HTMLファイルをブラウザで直接開き、サーバーなしで動作すること。 - ジョーカーを除く52枚のカードが重複や欠落なく表示されること。 - 4スート×13ランクが正しく画像へ対応していること。 - ゲーム開始ごとにカードがシャッフルされること。 - 同じランクでスートの異なる2枚を選ぶとペアが成立すること。 - 異なるランクの2枚を選ぶと約1秒後に裏向きへ戻ること。 - 同じカードを2回選べないこと。 - ペア成立済みのカードを再選択できないこと。 - 判定中の連続入力でも3枚以上が表向きにならないこと。 - 2枚目を開くたびに、成立、不成立を問わず手数が1増えること。 - 1枚だけ開いた状態では手数が増えないこと。 - 最初のカードを開くまで経過時間が進まないこと。 - 一時停止中に時間が進まず、盤面が隠されること。 - 判定待ちの状態で「最初から」を押しても、古いタイマーによる誤作動が起きないこと。 - 26組すべてが成立するとゲームクリアになること。 - ゲームクリア時にタイマーが停止すること。 - 「もう一度遊ぶ」で盤面、手数、経過時間、成立ペア数が初期化されること。 - 成績がlocalStorageへ保存され、直近30回分だけ表示されること。 - マウス、キーボード、画面タッチで操作できること。 - キーボードだけで26組すべてを揃えられること。 - PCとスマートフォン相当の画面幅でカードや操作部分が重ならないこと。 - カード画像を読み込めない場合に代替表示とエラー通知が行われること。 - JavaScriptの構文エラーや未処理の例外がないこと。 - インターネット通信が発生しないこと。 - Codexが可能な範囲でテスト用の内部状態またはデバッグ機能を利用し、通常のペア成立、不成立、連続入力、一時停止、ゲームクリアを含むプレイテストを行うこと。 - テスト用のデバッグ機能は配布版では無効化または削除すること。 - 実際に確認できなかった項目を「合格」と推測して報告しないこと。 - テスト結果を、実施内容、期待結果、実際の結果、合否が分かる形式で報告すること。
# 前提条件 - タイトルは「神経衰弱ゲーム」とする。 - バージョン番号、著作権者、MIT Licenseであることを画面に表示する。 - 仕様で分からないことがあり、その違いが実装結果へ大きく影響する場合はユーザーに質問する。 - 安全かつ合理的に判断できる軽微な事項は、判断内容を明記して作業を進める。 - HTML、CSS、JavaScriptを1つのHTMLファイルに記述する。 - カード画像は別ファイルとしてプロジェクトフォルダ内に配置してよい。 - クライアントPCのブラウザで動作し、スマートフォンでも利用できること。 - HTTPサーバーやNode.jsなどのサーバー技術は使用せず、ブラウザの機能だけで完結すること。 - Canvas APIまたはHTML要素を利用してゲーム画面を描画すること。 - 画面へ流し込む文字列は、プログラム内に定義した固定値および内部で計算した数値だけとする。 - コーディングは「Airbnb JavaScript Style Guide」に可能な限り準拠すること。 - プログラムファイルのコメントに、名称、バージョン、目的、動作環境、著作権表示および使用条件、インストール方法、お問い合わせを記載すること。 - ゲーム名、画面デザイン、説明文は独自のものとし、ユーザー提供素材を除き、第三者の画像、音声、コードを無断使用しないこと。
# 制約条件 - インターネットとのデータ送受信は行わないこと。 - CDNを含む外部ライブラリを使用せず、標準のHTML、CSS、JavaScriptだけで実装すること。 - ユーザー提供のカード画像を除き、外部画像、外部フォント、外部音声、アクセス解析、広告、Cookieを使用しないこと。 - プログラムがMIT Licenseおよび第三者の権利に違反していないこと。 - ユーザー提供のカード画像を利用・配布できる権利があることを前提とする。確認できない場合は配布前にユーザーへ確認すること。 - ユーザーの許可なくプロジェクトフォルダ外のファイルを変更しないこと。 - ユーザーの許可なく既存ファイルを削除しないこと。 - Gitの履歴を破壊する操作を行わないこと。
# 合格判定 - すべての実装とテストが完了したら、テスト結果を一覧で表示する。 - 合格項目、不合格項目、未確認項目を明確に区別する。 - 不合格項目がある場合は原因と修正方針を表示する。 - 未確認項目がある場合は未確認の理由とユーザーによる確認方法を表示する。 - テスト結果を表示した後、合格としてよいかユーザーに質問する。 - ユーザーが合格と回答するまでは、Gitへの最終コミットと配布用ZIPファイルの作成を行わない。 - ユーザーが不合格と回答した場合は、指摘内容を修正して再テストする。
# 簡易取扱説明書の作成 1)HTMLファイルと同じ場所に、簡易取扱説明書「README.txt」を作成する。 2)説明書には、各項目を「# 項目名」と表記して次を含める。 - プログラムの名称 - バージョン - 目的 - ゲーム・ルール - 動作環境 - 著作権表示および使用条件 - インストール方法 - 使い方 - 操作方法 - 成績の保存 - 既知の制限 - 変更履歴 - お問い合わせ 3)インストール方法には、ZIPファイルを展開し、memoryGame.htmlをブラウザで直接開く手順を記載する。 4)使い方には、ゲーム開始、一時停止、再開、再プレイ、終了の手順を記載する。 5)マウス、キーボード、画面タッチの操作方法を記載する。
# 著作権表示および使用条件 このプログラムは OpenAI社の Codex によって作成し、作者が動作を確認しました。 このプログラムは外部とのデータ通信を行いません。
本アプリケーションはMIT Licenseです。 商用を含む無償利用が可能です。自由に改造できます。 再配布の際は、下記の著作権表記、URLおよび本使用条件を必ず明記してください。
Copyright by (c)studio pahoo https://www.pahoo.org/
MITライセンスについては、下記のリンク先を参考にしてください。 http://ja.wikipedia.org/wiki/MIT_License http://www.opensource.org/licenses/mit-license.php
なお、本アプリケーションの利用または改造によって生じた得失については一切関知いたしません。また、二次利用先の組織・企業・団体の目的・内容・活動については一切関知いたしません。
# お問い合わせ ぱふぅ家のホームページ https://www.pahoo.org/
- サイト案内 - お問い合わせ
# プロジェクト・プロンプトの保存 - このプロンプトを、プロジェクトフォルダ内の「PROMPT.md」に保存する。 - 実装中にユーザーと合意して仕様を変更した場合は、PROMPT.mdにも変更内容を反映する。 - PROMPT.mdに記載されたバージョン番号と、プログラムおよびREADME.txtのバージョン番号を一致させる。
# リソース管理 1)作業開始前に、今回の作業が次のどれに該当するかユーザーに質問する。 - 新規作成(評価用) - 新規作成(配布用) - メジャーバージョンアップ - マイナーバージョンアップ - 不具合修正 2)回答に従い、バージョン番号を次のルールで決定する。 - 新規作成(評価用):バージョン0.1.0 - 新規作成(配布用):バージョン1.0.0 - メジャーバージョンアップ:現在のバージョン番号の整数部分を1増やし、小数部分を0にする。 - マイナーバージョンアップ:現在のバージョン番号の小数第1位を1増やし、それより下の桁を0にする。 - 不具合修正:現在のバージョン番号の小数第2位を1増やす。 3)バージョンアップの場合は、既存のバージョン番号をプログラムファイル、README.txt、PROMPT.mdまたはGit履歴から確認する。 4)既存のバージョン番号を確認できない場合は、推測せずユーザーに質問する。 5)決定したバージョン番号を、memoryGame.html、README.txt、PROMPT.md、ゲーム画面、配布用ZIPファイル名のすべてに反映する。 6)ユーザーがテスト結果を合格と判定した後、memoryGame.html、README.txt、PROMPT.md、および必要なカード画像をGitへコミットする。 7)Gitリポジトリが存在しない場合やコミットできない場合は、勝手に新規リポジトリを作成せず、理由をユーザーに報告して指示を求める。 8)コミットメッセージには、プログラム名、バージョン番号、変更区分が分かる内容を使用する。
# 配布ファイルの作成 1)ユーザーがテスト結果を合格と判定した後、次の内容を1つのZIPファイルに圧縮する。 - memoryGame.html - README.txt - PROMPT.md - ゲームで使用するカード画像一式 2)ZIPファイル名は「memoryGame_バージョン番号.zip」とする。 例: ```text memoryGame_0.1.0.zip memoryGame_1.0.0.zip ``` 3)ZIP内でもHTMLからカード画像を相対パスで正しく参照できる構成を維持する。 4)ZIPファイル内に一時ファイル、テスト用ファイル、Git管理ファイル、デバッグ専用ファイルを含めない。 5)ZIPファイル作成後、52枚の画像を含む内容物とファイル名が正しいことを確認する。 6)完了後、作成したZIPファイルの絶対パスをユーザーに伝える。
この仕様書は、添付のプログラムを作ったあとに、本項で述べた食い違いを反映して手直ししたものである。「自動スクロール」という曖昧な表現を「利用者の操作で全件を閲覧できる」に改め、列数を明示し、非同期エラーの捕捉を追加し、「# アクセシビリティ」の節と、innerHTMLの使用条件にあたる1行を書き足してある。仕様書は一度書いて終わりではなく、できあがったものを見て育てていくものである。

なお、この仕様書には画像素材に関する条件が随所に入っている。カード画像はユーザーが用意するものであり、Codexが勝手に生成したり、インターネットから取得したりしてはならない。トランプの絵柄には著作権が及ぶものもあるため、配布する権利があることを確認してから同梱するという一文も入れてある。素材を伴うプログラムでは、この確認を仕様書の段階で組み込んでおくとよい。
完成した神経衰弱ゲーム
完成した神経衰弱ゲーム
AI生成コンテンツ / AI-generated content

参考サイト

(この項おわり)
header