ブラウザ版魚へん漢字タイピングの実装を解説
魚へん漢字のデータ管理から、複数の読みへの対応、ローマ字入力の候補判定、制限時間、ヒント、音声、難易度別ランキングまで、ゲームページの実装を順番に紹介します。
魚へん漢字タイピングは、画面に表示された魚へんの漢字を読み、答えをローマ字で入力するゲームです。75秒、60秒、50秒の3つの難易度から選び、正解数とミス数を記録します。
ページの共通部分はPHPで読み込み、ゲームの進行はブラウザ上のJavaScriptで処理しています。漢字と読みのデータをもとに問題を出し、入力のたびに正解候補を絞り込む構成にすることで、複数のローマ字表記を自然に受け付けられるようにしました。
漢字と複数の読みをデータで管理する
出題データは、漢字と読みのカンマ区切り文字列を組にした配列です。「鯏」のように複数の読みがある漢字や、「鰯」のように異体字を含む項目も、同じ形式で追加できます。
const raw = [
['鯵', 'あじ'],
['鰺', 'あじ'],
['鯏', 'あさり,うぐい'],
['鰒', 'あわび,ふぐ']
];
const data = raw.map(([kanji, list]) => ({
kanji,
readings: list.split(',')
}));画面へ表示するのはkanji、入力候補を作るのはreadingsです。漢字データと表示処理を分離しておくと、問題を増やすときにゲーム本体の条件分岐を変更せずに済みます。ページ下部には登録文字数と読み数も表示し、収録内容を確認できるようにしています。
ひらがなからローマ字候補を作る
「し」にはsi、shi、ci、「ん」にはn、nn、xnなど複数の入力方法があります。まず読みを、二文字の拗音を優先しながら単位へ分解します。
function units(kana) {
const result = [];
for (let i = 0; i < kana.length;) {
const pair = kana.slice(i, i + 2);
if (map[pair]) {
result.push(pair);
i += 2;
} else {
result.push(kana[i++]);
}
}
return result;
}単位へ分けたあと、各単位の候補を再帰的に組み合わせます。促音の「っ」は次の子音を重ねる入力とltu、xtuを用意し、撥音の「ん」も複数の表記を登録します。
function variants(kana) {
const parts = units(kana);
const memo = new Map();
function build(index) {
if (index === parts.length) return [''];
if (memo.has(index)) return memo.get(index);
const tails = build(index + 1);
let result = [];
const heads = map[parts[index]] || [parts[index]];
heads.forEach(head =>
tails.forEach(tail => result.push(head + tail))
);
result = [...new Set(result)].slice(0, 512);
memo.set(index, result);
return result;
}
return build(0);
}候補数には上限を設け、入力のたびに大量の組み合わせを作らないようにしています。漢字に複数の読みが登録されている場合は、読みごとに候補を作って一つの配列へまとめます。
問題袋で同じ漢字の連続を避ける
毎回ランダムに選ぶだけでは、同じ漢字が続くことがあります。そこで全データをシャッフルした袋を作り、末尾から一つずつ取り出します。袋が空になったら再びシャッフルします。
let bag = [];
let current;
function shuffle(items) {
const result = [...items];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
function refill() {
bag = shuffle(data);
if (current && bag.length > 1 &&
bag[bag.length - 1].kanji === current.kanji) {
[bag[0], bag[bag.length - 1]] =
[bag[bag.length - 1], bag[0]];
}
}Fisher-Yates法でシャッフルし、前回と同じ漢字が次に選ばれそうな場合は袋の先頭と交換します。全データを一巡するまでは同じ項目を出さないため、短いゲームでも問題が偏りにくくなります。
入力途中の候補を先頭一致で絞り込む
キー入力のたびに、現在の入力文字列で始まる候補だけを残します。候補が一つでもあれば入力を受け付け、完全一致した候補が見つかった時点で正解にします。候補がなくなった場合はミスとして扱います。
const next = typed + key;
const filtered = options.filter(option =>
option.roman.startsWith(next)
);
if (!filtered.length) {
miss++;
combo = 0;
wrong();
return;
}
typed = next;
options = filtered;
if (options.find(option => option.roman === typed)) {
correct(options.find(option => option.roman === typed));
}たとえば「し」に対してsを入力した時点ではsiとshiの両方を残せます。入力できる候補がなくなった文字だけをミスとするため、表記の違いを意識せずに入力できます。日本語入力中のキーイベントや、修飾キー付きの入力は判定対象から外しています。
制限時間をゲームループで更新する
残り時間はrequestAnimationFrameのループで更新します。前回のフレームから経過した秒数を引くため、端末のフレームレートに依存しにくいタイマーになります。
function loop(now) {
if (!running) return;
time -= Math.min((now - last) / 1000, 0.1);
last = now;
if (time <= 0) {
time = 0;
hud();
finish();
return;
}
hud();
raf = requestAnimationFrame(loop);
}開始時に難易度に対応した75秒、60秒、50秒を設定し、時間が0になったら入力を停止して結果画面へ切り替えます。正解後やヒント後は短いタイマーで次の問題を出すため、現在の問題が二重に進まないようにclearTimeoutも使っています。
正解、ミス、Enterヒントを状態へ反映する
正解すると正解数とコンボを増やし、ミスするとミス数を増やしてコンボを0へ戻します。分からないときにEnterを押すと、登録済みの読みを表示する代わりに残り時間を5秒減らします。
function correct(option) {
accepting = false;
count++;
combo++;
bestCombo = Math.max(bestCombo, combo);
answer.textContent = option.kana + '!';
setTimeout(() => {
if (running) pick();
}, 260);
}
function hint() {
if (!running || !accepting) return;
accepting = false;
time = Math.max(0, time - 5);
combo = 0;
answer.textContent = current.readings.join('・');
setTimeout(() => {
if (running) pick();
}, 900);
}acceptingを切り替えることで、正解やヒントの表示中に追加入力を受け付けないようにしています。画面の文字だけでなく、ゲーム内部のカウントと入力受付状態を同時に更新することがポイントです。
Web Audio APIで効果音とBGMを作る
音声ファイルを用意せず、Web Audio APIの発振器とゲインから短い効果音を生成しています。キー入力、正解、ミス、ゲーム終了で周波数や波形を変え、BGMは一定間隔でメロディーを再生します。
function tone(freq, duration = .06, type = 'sine') {
if (!sound) return;
const context = audioContext();
if (!context) return;
const oscillator = context.createOscillator();
const gain = context.createGain();
const now = context.currentTime;
oscillator.type = type;
oscillator.frequency.value = freq;
gain.gain.setValueAtTime(.0001, now);
gain.gain.exponentialRampToValueAtTime(.08, now + .01);
gain.gain.exponentialRampToValueAtTime(.0001, now + duration);
oscillator.connect(gain).connect(context.destination);
oscillator.start(now);
oscillator.stop(now + duration + .02);
}AudioContextの作成はゲーム開始ボタンを押したあとに行います。ブラウザの自動再生制限に対応するためです。効果音とBGMの設定を分け、音を切ってもゲーム自体は続けられるようにしています。
難易度別ランキングをlocalStorageへ保存する
ゲーム終了時には正解数とミス数を保存し、正解数が多い順、同数ならミスが少ない順に並べて上位5件を表示します。75秒、60秒、50秒の記録が混ざらないよう、制限時間を保存キーへ含めています。
function storageKey() {
return 'fish-kanji-typing-' + [75, 60, 50][level];
}
function save() {
const rows = scores();
rows.push({ count, miss });
rows.sort((a, b) => b.count - a.count || a.miss - b.miss);
localStorage.setItem(
storageKey(),
JSON.stringify(rows.slice(0, 5))
);
}localStorageを使うため、サーバー側のアカウントがなくても同じブラウザで自己ベストを確認できます。読み書きはtry...catchで囲み、保存できない環境でもゲーム本体が止まらないようにしています。
ゲームの流れを状態と表示に分ける
実装を読むときは、start、pick、キー入力処理、correct、hint、finishの順に追うと、開始から問題出題、入力、終了までの流れが分かります。ゲームの状態はJavaScriptの変数で管理し、HUDや漢字カードはその状態を表示する役割に分けています。
問題データを追加するときは、漢字と読みを登録するだけで、候補生成、入力判定、正解数、ランキングまで同じ処理を利用できます。実際に遊びながら、表記の違うローマ字、Enterによるヒント、難易度ごとの残り時間を試すと、各処理の関係を確認しやすくなります。
ゲーム本体は魚へん漢字タイピングで遊べます。収録漢字の一覧はゲームページからダウンロードできるため、タイピングと魚へん漢字の学習を一緒に楽しめます。