GAME

英会話タイピングの実装を解説

シチュエーション別の英語問題、複数の正解候補、入力途中の判定、5段階のレッスン、効果音まで、ゲームページの実装を順番に紹介します。

英会話タイピングは、挨拶、カフェ、ホテル、空港など8つの場面で使う英語を入力するゲームです。各場面には40問、全体で320問を用意し、短い表現から長い一文へ進みます。同じ意味でも複数の言い方があるため、正解候補も一つの問題にまとめて持たせています。

ページの共通部分はPHPで読み込み、ゲームの状態管理とキーボード入力はJavaScriptで行っています。画面の場面画像やカード、進行状況はCSSとDOMを組み合わせて更新します。

場面ごとの問題と別解を配列で管理する

問題は場面オブジェクトのitemsに、日本語の意味と英語の正解候補をセットで登録しています。たとえば「こんにちは」にはHiとHelloのどちらも正解として設定できます。

問題データの一部JAVASCRIPT
const situations = [
    {
        id: 'greeting',
        name: '挨拶',
        iconPos: '0 0',
        pos: '0 0',
        items: [
            ['こんにちは', ['Hi', 'Hello']],
            ['お元気ですか?', ['How are you?']]
        ]
    },
    {
        id: 'cafe',
        name: 'カフェ',
        iconPos: '-48px 0',
        pos: '-100% 0',
        items: [
            ['コーヒーをください', ['Coffee, please.', 'Can I have a coffee?']]
        ]
    }
];

正解を文字列一つにせず配列にしたことで、自然な言い換えを許可できます。問題文、日本語表示、正解表示、判定処理が同じデータを参照するため、問題を追加するときも修正箇所が分散しません。

短い問題から長い問題へ並べ替える

各場面には最初の18問に加えて、より長い表現の問題をexpansionsから追加しています。問題を登録した順番に頼らず、正解候補の単語数を調べて並べ替えることで、単語や短い一言から会話文へ自然に進められます。

単語数で問題を並べ替えるJAVASCRIPT
function normalizeForSort(text) {
    return text.toLowerCase()
        .replace(/[’‘]/g, "'")
        .replace(/[?.!,]/g, '')
        .replace(/\s+/g, ' ')
        .trim();
}

function wordCount(text) {
    return normalizeForSort(text).split(' ').filter(Boolean).length;
}

situations.forEach(situation => {
    situation.items.push(...expansions[situation.id]);
    situation.items.sort((left, right) =>
        Math.min(...left[1].map(wordCount)) - Math.min(...right[1].map(wordCount))
    );
});

並べ替えの基準には、複数ある正解候補のうち最も短い候補を使っています。別解の長さによってレッスンの順番が不自然にならないようにするためです。

入力比較用に英語を正規化する

入力された英語と正解候補をそのまま比較すると、大文字、小文字、アポストロフィ、文末の記号の違いで不正解になります。そこで比較前に、両方を同じ形へ正規化します。

英語を比較しやすい形にするJAVASCRIPT
function normalize(text) {
    return text.toLowerCase()
        .replace(/[’‘]/g, "'")
        .replace(/[?.!,]/g, '')
        .replace(/\s+/g, ' ')
        .trim();
}

function pick() {
    const item = current();
    typed = '';
    options = item[1].map(normalize);
    accepting = true;
    el.japanese.textContent = item[0];
    el.typed.textContent = '';
    el.guide.textContent = '英語を入力してください';
}

表示する正解候補は元の表記を保ち、判定用のoptionsだけを正規化しています。これにより、クリア後にHi / Helloのような読みやすい答えを見せられます。

入力途中の候補を絞り込む

キーを1文字押すたびに、入力途中の文字列で始まる正解候補だけを残します。候補がなくなった場合はミスとして扱い、入力中の文章は消さずに、正しい文字を続けて入力できるようにしています。

入力を候補と照合するJAVASCRIPT
function key(event) {
    if (event.isComposing || !accepting) return;
    if (event.key === 'Enter') {
        skip();
        return;
    }

    const key = event.key.toLowerCase().replace(/[’‘]/g, "'");
    if (!/^[a-z ']$/.test(key)) return;
    event.preventDefault();

    const next = typed + key;
    const filtered = options.filter(answer => answer.startsWith(next));
    if (!filtered.length) {
        wrong();
        return;
    }

    typed = next;
    options = filtered;
    el.typed.textContent = typed;
    if (options.includes(typed)) solved(typed);
}

startsWithで前方一致を調べるため、たとえばhを入力した時点ではhiとhelloの両方が候補に残ります。候補が一つになった後も、完全一致するまでは入力を受け付けます。対象キーを英字、スペース、アポストロフィに限定しているので、問題にない記号が混ざることもありません。

正解・ミス・スキップをゲーム状態で分ける

ゲーム中は、現在の問題番号、正解数、ミス数、入力文字列、受付中かどうかを状態として持ちます。正解した瞬間に次の入力を受け付けると二重判定が起きるため、先にacceptingをfalseにしてから、少し待って次の問題へ進みます。

正解時に次の問題へ進むJAVASCRIPT
function solved(answer) {
    accepting = false;
    correct++;
    el.typed.textContent = current()[1].join(' / ');
    el.message.textContent = '正解!';
    animate('is-correct');
    tone(660);

    setTimeout(() => {
        index++;
        if (index >= currentSituation().items.length) {
            finish();
        } else {
            pick();
        }
    }, 1800);
}

function wrong() {
    miss++;
    el.message.textContent = '入力を確認してください';
    animate('is-wrong');
    tone(180, .08, 'square');
}

Enterキーのスキップでは、最初の正解と別解を表示してから次へ進みます。分からない問題を飛ばしても答えを確認できるため、ゲームを練習として使えます。

8問ごとにレッスン表示を切り替える

全40問を一度に同じ難しさとして見せず、8問ずつ5レッスンに分けています。問題番号だけからレッスン名を計算できるため、問題データと表示文を別々に管理する必要がありません。

問題番号からレッスンを決めるJAVASCRIPT
function levelFor(index) {
    if (index < 8) return 'LESSON 1 単語・ひとこと';
    if (index < 16) return 'LESSON 2 短いフレーズ';
    if (index < 24) return 'LESSON 3 基本の会話';
    if (index < 32) return 'LESSON 4 条件を変える';
    return 'LESSON 5 長い一文';
}

el.level.textContent = levelFor(index);
el.progress.textContent = index + 1;

問題を追加しても、8問単位という進行ルールはそのまま使えます。日本語の意味、レッスン名、現在位置を同じカードに表示することで、学習の進み具合も分かりやすくしています。

シチュエーションをボタンと背景へ反映する

画面上部のシチュエーションボタンは、situations配列から動的に生成します。選択中のボタンにis-activeを付け、背景画像はCSSスプライトの位置を切り替えます。データと表示を同じ配列から作るので、場面を増やしてもHTMLを手作業で増やさずに済みます。

場面ボタンを生成するJAVASCRIPT
function renderButtons() {
    el.situations.innerHTML = situations.map((situation, i) => `
        <button class="ect-situation${i === situationIndex ? ' is-active' : ''}"
                data-situation="${situation.id}">
            <span class="ect-situation-icon"
                  style="background-position:${situation.iconPos}"></span>
            <span>${situation.name}</span>
        </button>
    `).join('');
}

function setScene() {
    const situation = situations[situationIndex];
    el.scene.style.backgroundPosition = situation.pos;
    el.scene.setAttribute('aria-label', `${situation.name}の場面`);
    el.situation.textContent = situation.name;
}

背景画像を一枚のスプライトとして扱うことで、場面を切り替えるたびに画像を読み込み直す必要がありません。ボタンのアイコンとメイン画面の背景で同じ場面データを使うため、選択状態のずれも防げます。

Web Audio APIで短い効果音を作る

入力のたび、正解、ミス、クリアで音を変えるため、音声ファイルを複数用意する代わりにWeb Audio APIで短い音を生成しています。AudioContextはユーザー操作の後に作成し、ブラウザの自動再生制限にも対応します。

周波数と波形から音を作るJAVASCRIPT
function tone(frequency, duration = .06, type = 'sine') {
    if (!sound) return;
    const audioContext = context();
    if (!audioContext) return;

    const oscillator = audioContext.createOscillator();
    const gain = audioContext.createGain();
    oscillator.type = type;
    oscillator.frequency.value = frequency;
    gain.gain.setValueAtTime(.04, audioContext.currentTime);
    gain.gain.exponentialRampToValueAtTime(
        .001,
        audioContext.currentTime + duration
    );
    oscillator.connect(gain).connect(audioContext.destination);
    oscillator.start();
    oscillator.stop(audioContext.currentTime + duration);
}

通常入力は短い正弦波、ミスは低い矩形波、正解やクリアは異なる周波数で鳴らします。音量を小さくしているため、タイピングの邪魔をせずに操作結果を伝えられます。サウンドボタンでsoundを切り替えれば、音が不要な環境でも遊べます。

キーボード操作と日本語入力への対策

入力欄を置かず、ドキュメント全体のkeydownを監視する方式にしています。ゲーム開始前のスペースキー、プレイ中の英字とスペース、Enterによるスキップを一つのハンドラーで処理できます。

入力イベントを登録するJAVASCRIPT
document.addEventListener('keydown', event => {
    if (event.isComposing) return;
    if (event.ctrlKey || event.metaKey || event.altKey) return;
    key(event);
});

isComposingを確認しているのは、日本語入力中のキーイベントを英語の一文字として誤判定しないためです。また、Ctrlキーなどとの組み合わせはブラウザや支援機能の操作に使われるため、ゲームの入力から除外しています。

クリア表示と復習へつなげる

40問を終えると、正解数とミス数をモーダルに表示します。ゲーム内で表示した問題の答えは、ページからダウンロードできる解答一覧にもまとめています。プレイ結果を表示して終わりにせず、分からなかった表現を復習できるようにするためです。

クリア結果を表示するJAVASCRIPT
function finish() {
    running = false;
    accepting = false;
    el.modalTitle.textContent = `${situations[situationIndex].name} クリア!`;
    el.modalText.innerHTML = `正解 ${correct}問 ミス ${miss}回`;
    el.start.textContent = 'もう一度遊ぶ';
    el.overlay.classList.remove('is-hidden');
    tone(660, .12);
    setTimeout(() => tone(880, .16), 120);
}

実装して分かったこと

英会話タイピングでは、単に入力された文字列が一致したかを調べるだけでなく、別解の管理、入力途中の候補、問題の難易度、場面の切り替えを一つの状態管理にまとめる必要があります。特に正解候補を前方一致で絞り込む方式にすると、言い換えを許可しながら一文字ごとの入力感も保てます。

実際のコードは英会話タイピングのページで確認できます。まず問題データとnormalize、次にkey、最後にsolvedとfinishの順で読むと、入力から画面更新までの流れを追いやすくなります。