【JavaScript】e-typing風の本格派タイピングゲームを作る

2022/08/26
28
e-typing画像

前記事のニュースティッカーに続き、今回はJavaScriptでe-typingの腕試し風タイピングゲームを作りました。タイピングに青春の一部を捧げた私が贈る、本格派タイピングゲームです。

本家同様の複数入力パターン対応、スタート画面での各種設定の切り替え機能、タイパー的視点からちょっと欲しい機能なども完備しています。

今回作成したタイピングゲームは、メモ帳があればローカル環境でも簡単にプレイすることができます。ローカル環境での遊び方やゲームの仕様については記事の中で詳しくご紹介します。

特徴

今回のタイピングゲームの特徴は以下の通りです。

  • 各種設定の切り替え機能
  • キーガイド
  • 進捗状況を示すプログレスバー
  • タイプした部分の色付け
  • 文章が長い場合の自動スクロール機能
  • ミスタイプ時のエフェクト
  • 本家と同様のタイピング結果表示
  • 「ミスだけ」「もう1回」機能

加えて、冒頭でも触れたとおり複数の入力パターンに対応しています。

例)「し」:「si」「shi」「ci」、「ん」:「nn」「xn」「n」(省略可能な場合のみ)など

ただし、本家e-typingでも対応していないような特殊な入力方法(例えば「しょ」を「silyo」と打つなど)は受け付けません。計算コストがとんでもないことになるので…(^^;

追加要素

以下は私が追加実装したオリジナル機能です。

リアルタイムWPM表示

現在の入力速度が気になることがあるので付けてみました。

(スタート画面の設定でON/OFF設定可能)

スピードバー表示

入力速度が直感的にわかったら便利だなぁと思って付けてみました。700WPM(≒11.67キー/秒)を基準値としてどれくらいの速度かがバーの長さで示されます。

(基準値はソースコード内で変更可能)

サンプル画像1

※1
本家の仕様と挙動を再現していますが、プログラム自体は私の完全オリジナルです。本家の内部処理については一切把握していません。

※2
運指ガイドはキーボードで力尽きて諦めました。本家と違い運指ガイドなしですが許してあげてください。

デモ

百聞は一見に如かず。まずはどんな感じかプレイしてみてください。

下のボタンをクリックするとゲーム画面が開きます。

※スクリーンサイズの都合上、解像度(横)748px以上のデバイスでのプレイを推奨します。

e-typing風タイピングゲーム
ボカロ曲名
ローマ字表示(R)
かな表示(K)
キーガイド(G)
WPM表示(W)
スピードバー(S)

日本語入力モードをオフにしてください

スペースキーで開始

(終了はEscキーです)

1
2
3
4
5
6
7
8
9
0
-
Q
W
E
R
T
Y
U
I
O
P
@
A
S
D
F
G
H
J
K
L
;
:
shift
Z
X
C
V
B
N
M
,
.
/
shift
space
今回のタイピング結果
前回の結果
---

ソースコードと仕様

ソースコード

こちらがゲームのソースコードです。

<!-- Typing game -->
<div id="typing-start">
  <p>下のボタンをクリックするとゲーム画面が開きます。</p>
  <button id="open-button" type="button">今すぐスタート!</button>
  <p class="note">※スクリーンサイズの都合上、解像度(横)748px以上のデバイスでのプレイを推奨します。</p>
  <noscript>
    <p class="msg">本ゲームをプレイするにはJavaScriptを有効化してください。</p>
  </noscript>
</div>
<div id="game-screen">
  <div id="game-header">
    <div class="description">e-typing風タイピングゲーム</div>
    <button id="close-button1" type="button">閉じる</button>
  </div>
  <div id="game-banner">
    <a href="https://itblogger-note.blogspot.com" rel="noopener" target="_blank">
      <img alt="バナー画像" width="728" height="90"
        src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiWC_jWwUfs06k0XZ3HbhwfgRZatZR035aJ1xOfjGLDls9gXjuY8UWHXjwNqOBCK1oj48VEdx-0jQmigl9Qj1-Rx5sx0YT4HHuX2QFdzmIoKYSVj1xjstgD9PzYcV3gczspD-o6Em9Kz2Q/s0-e365/banner.png" />
    </a>
  </div>
  <div id="game-body">
    <div id="game-view1">
      <div id="game-title">ボカロ曲名</div>
      <div id="game-explain"></div>
      <button id="start-button" type="button">スタート</button>
      <div id="game-func">
        <span>ローマ字表示(R)</span>
        <div class="switch-btn">
          <button class="on-btn btn show" type="button">ON</button>
          <button class="off-btn btn" type="button">OFF</button>
        </div>
        <span>かな表示(K)</span>
        <div class="switch-btn">
          <button class="on-btn btn show" type="button">ON</button>
          <button class="off-btn btn" type="button">OFF</button>
        </div>
        <span>キーガイド(G)</span>
        <div class="switch-btn">
          <button class="on-btn btn show" type="button">ON</button>
          <button class="off-btn btn" type="button">OFF</button>
        </div>
        <span>WPM表示(W)</span>
        <div class="switch-btn">
          <button class="on-btn btn show" type="button">ON</button>
          <button class="off-btn btn" type="button">OFF</button>
        </div>
        <span>スピードバー(S)</span>
        <div class="switch-btn">
          <button class="on-btn btn show" type="button">ON</button>
          <button class="off-btn btn" type="button">OFF</button>
        </div>
      </div>
    </div>
    <div id="game-view2">
      <div id="text-container">
        <div id="miss-type-screen"></div>
        <div id="start-msg">
          <p>日本語入力モードをオフにしてください</p>
          <p><em>スペースキーで開始</em></p>
          <p>(終了はEscキーです)</p>
        </div>
        <div id="example"></div>
        <div id="kana"></div>
        <div id="sentence"></div>
        <div id="progress-bar"></div>
        <div id="current-wpm"></div>
        <div id="speed-bar">
          <div class="cover"></div>
        </div>
      </div>
      <div id="virtual-keyboard">
        <div class="deco_key1"></div>
        <div class="key_1">1</div>
        <div class="key_2">2</div>
        <div class="key_3">3</div>
        <div class="key_4">4</div>
        <div class="key_5">5</div>
        <div class="key_6">6</div>
        <div class="key_7">7</div>
        <div class="key_8">8</div>
        <div class="key_9">9</div>
        <div class="key_0">0</div>
        <div class="key_hyphen">-</div>
        <div class="deco_key2"></div>
        <div class="deco_key3"></div>
        <div class="deco_key4"></div>
        <div class="deco_key5"></div>
        <div class="key_q">Q</div>
        <div class="key_w">W</div>
        <div class="key_e">E</div>
        <div class="key_r">R</div>
        <div class="key_t">T</div>
        <div class="key_y">Y</div>
        <div class="key_u">U</div>
        <div class="key_i">I</div>
        <div class="key_o">O</div>
        <div class="key_p">P</div>
        <div class="key_atmark">@</div>
        <div class="deco_key6"></div>
        <div class="key_Enter"></div>
        <div class="deco_key7"></div>
        <div class="key_a">A</div>
        <div class="key_s">S</div>
        <div class="key_d">D</div>
        <div class="key_f">F</div>
        <div class="key_g">G</div>
        <div class="key_h">H</div>
        <div class="key_j">J</div>
        <div class="key_k">K</div>
        <div class="key_l">L</div>
        <div class="key_semicolon">;</div>
        <div class="key_colon">:</div>
        <div class="deco_key8"></div>
        <div class="key_lShift">shift</div>
        <div class="key_z">Z</div>
        <div class="key_x">X</div>
        <div class="key_c">C</div>
        <div class="key_v">V</div>
        <div class="key_b">B</div>
        <div class="key_n">N</div>
        <div class="key_m">M</div>
        <div class="key_comma">,</div>
        <div class="key_period">.</div>
        <div class="key_slash">/</div>
        <div class="deco_key9"></div>
        <div class="key_rShift">shift</div>
        <div class="deco_key10"></div>
        <div class="deco_key11"></div>
        <div class="deco_key12"></div>
        <div class="deco_key13"></div>
        <div class="key_space">space</div>
        <div class="deco_key14"></div>
        <div class="deco_key15"></div>
        <div class="deco_key16"></div>
        <div class="deco_key17"></div>
        <div class="deco_key18"></div>
      </div>
    </div>
    <div id="game-result">
      <div id="current-result">
        <div class="result-title">今回のタイピング結果</div>
        <div id="example-list"></div>
        <div class="result-data"></div>
      </div>
      <div id="prev-result">
        <div class="result-title">前回の結果</div>
        <div class="result-data"></div>
      </div>
      <div id="result-comment">
        <div class="container">---</div>
      </div>
      <div id="btn-area">
        <button id="replay-button" class="btn" type="button">もう1回</button>
        <button id="close-button2" class="btn" type="button">閉じる</button>
      </div>
    </div>
  </div>
</div>
<script>
  /*
  e-typing-like typing game v1.1 created with passion by Fuma(@fuma_it)
  Have fun :)
  */
  (function (window, document) {
    const overlay = document.createElement('div');
    overlay.id = 'game-overlay';
    const game = document.getElementById('game-screen');
    const button1 = document.getElementById('close-button1');
    const button2 = document.getElementById('open-button')
    const button3 = document.getElementById('start-button');
    const button4 = document.getElementById('replay-button');
    const button5 = document.getElementById('close-button2');
    const view1 = document.getElementById('game-view1');
    const gameFunc = document.getElementById('game-func');
    const onBtns = gameFunc.querySelectorAll('.on-btn');
    const offBtns = gameFunc.querySelectorAll('.off-btn');
    const view2 = document.getElementById('game-view2');
    const result = document.getElementById('game-result');
    const mScreen = document.getElementById('miss-type-screen');
    const startMsg = document.getElementById('start-msg');
    const example = document.getElementById('example');
    const kana = document.getElementById('kana');
    const sentence = document.getElementById('sentence');
    const progress = document.getElementById('progress-bar');
    const keyboard = document.getElementById('virtual-keyboard');
    const space = keyboard.querySelector('.key_space');
    let wordJP1 = ['いーあるふぁんくらぶ', '千本桜', 'マトリョシカ', 'ロキ', '文学少女インセイン', 'ロストワンの号哭', 'ゴーストルール', '神っぽいな', 'エイリアンエイリアン', 'こちら、幸福安心委員会です。', '脳漿炸裂ガール', '妄想税', 'ワールズエンド・ダンスホール', 'クワガタにチョップしたらタイムスリップした']; // 表示文章
    let wordJP2 = ['いーあるふぁんくらぶ', 'せんぼんざくら', 'まとりょしか', 'ろき', 'ぶんがくしょうじょいんせいん', 'ろすとわんのごうこく', 'ごーすとるーる', 'かみっぽいな', 'えいりあんえいりあん', 'こちら、こうふくあんしんいいんかいです。', 'のうしょうさくれつがーる', 'もうそうぜい', 'わーるずえんど・だんすほーる', 'くわがたにちょっぷしたらたいむすりっぷした']; // ひらがな文章
    let wordRs; // ローマ字データ1
    let wordR; // ローマ字データ2
    let record; // タイプした文章の記録
    let recordHTML;
    let recordM = []; // ミスした文章のインデックス
    let weakKeys; // 苦手キー格納用
    let gameData = []; // タイピング結果
    let backup1 = [];
    let backup2 = [];
    let isFirst = true;
    let isOpen = false;
    let startFlag = false;
    let sWait = false;
    let playing = false;
    let nFlag = false;
    let missFlag = false;
    let isStopped = false;
    let moPlay = false;
    let maxNum = 10; // 出題数の上限
    let random = true; // ランダム出題
    let resCmt = true; // 結果画面のコメント
    let flagR = true; // ローマ字表示
    let flagK = true; // かな表示
    let flagG = true; // キーガイド
    let flagW = true; // リアルタイムのWPM
    let flagS = true; // スピードバー
    let flags = [flagR, flagK, flagG, flagW, flagS];
    let ridx, limit, begin, count, idx1, idx2, pattern, temp, correct, miss;
    let over1, over2, over3, left1, left2, left3;

    // ウィンドウオープン
    function open() {
      isOpen = true;
      overlay.style.display = 'block';
      overlay.style.width = document.body.clientWidth + 'px';
      overlay.style.height = document.body.clientHeight + 'px';
      game.style.display = 'block';
      game.style.top = window.pageYOffset + window.innerHeight / 2 - game.clientHeight / 2 + 'px';
      game.style.left = window.innerWidth / 2 - game.clientWidth / 2 + 'px';

      if (isFirst) {
        document.body.appendChild(overlay);
        document.body.appendChild(game);
        document.head.insertAdjacentHTML('beforeend', '<style id="custom-css"></style>');
      } else {
        view1.style.display = 'table-cell';
        view2.style.display = 'none';
        result.style.display = 'none';
      }

      let fData = localStorage.getItem('flags');
      if (fData) {
        fData = JSON.parse(fData);
        for (let i = 0; i < fData.length; i++) {
          if (!fData[i]) onBtns[i].click();
        }
      }

      isFirst = false;
    }

    // スタート処理
    function start() {
      view1.style.display = 'none';
      view2.style.display = 'block';
      startMsg.style.display = 'block';

      startFlag = true;
      sWait = true;
      space.classList.add('active');

      flagR = flags[0];
      flagK = flags[1];
      flagG = flags[2];
      flagW = flags[3];
      flagS = flags[4];
      flags = [flagR, flagK, flagG, flagW, flagS];
      localStorage.setItem('flags', JSON.stringify(flags));
    }

    // カウントダウン処理
    function ready() {
      startMsg.style.display = 'none';
      const count = document.createElement('div');
      count.id = 'countdown';
      startMsg.after(count);
      let readyTime = 3;
      count.innerHTML = readyTime;
      const readyTimer = setInterval(() => {
        readyTime--;
        if (readyTime == 0) {
          clearInterval(readyTimer);
          count.remove();
          gameInit();
        }
        count.innerHTML = readyTime;
      }, 1000);
    }

    // ゲーム開始処理
    function gameInit() {
      count = 0;
      idx1 = 0;
      idx2 = 0;
      temp = '';
      correct = 0;
      miss = 0;
      ridx = [];
      record = [];
      recordHTML = '';
      weakKeys = [];
      nFlag = false;
      missFlag = false;

      if (moPlay) {
        let missJP1 = [];
        let missJP2 = [];
        for (let i = 0; i < recordM.length; i++) {
          missJP1.push(wordJP1[recordM[i]]);
          missJP2.push(wordJP2[recordM[i]]);
        }
        if (backup1.length == 0) {
          backup1 = [...wordJP1];
          backup2 = [...wordJP2];
        }
        wordJP1 = missJP1;
        wordJP2 = missJP2;
      } else {
        if (backup1.length > 0) {
          wordJP1 = [...backup1];
          wordJP2 = [...backup2];
          backup1 = [];
          backup2 = [];
        }
      }
      recordM = [];

      let idx;
      let a = [...Array(wordJP2.length).keys()];
      if (random) {
        while (a.length > 0) {
          idx = Math.floor(Math.random() * a.length);
          ridx.push(a[idx]);
          a.splice(idx, 1);
        }
      } else {
        ridx = a;
      }

      wordRs = [];
      for (let i = 0; i < wordJP2.length; i++) {
        wordRs.push(kanaToRoman(wordJP2[i]));
      }
      limit = (maxNum < wordJP2.length) ? maxNum : wordJP2.length;
      playing = true;
      begin = new Date();
      wordSet();

      let style = '';
      if (!flagR) {
        style += '#sentence span:not(.typed) {opacity: 0;}';
      }
      if (!flagK) {
        style += '#kana > div {opacity: 0;}';
      }
      document.getElementById('custom-css').innerHTML = style;

      if (flagW) {
        const cWPM = document.getElementById('current-wpm');
        cWPM.style.display = 'block';
        cWPM.innerHTML = 'WPM: 0.00';
        const id = setInterval(() => {
          let time = new Date() - begin;
          let speed = correct / time * 60 * 1000;
          if (playing) {
            cWPM.innerHTML = 'WPM: ' + speed.toFixed(2);
          } else {
            clearInterval(id);
            cWPM.innerHTML = '';
            cWPM.style.display = 'none';
          }
        }, 100);
      }

      if (flagS) {
        const speedBar = document.getElementById('speed-bar');
        const cover = speedBar.querySelector('.cover');
        speedBar.style.display = 'block';
        cover.style.transform = 'none';
        const id = setInterval(() => {
          let time = new Date() - begin;
          let speed = correct / time * 60 * 1000;
          if (playing) {
            let scale = 1 - speed / 700;
            cover.style.transform = 'scaleX(' + scale + ')';
          } else {
            clearInterval(id);
            cover.style.transform = 'none';
            speedBar.style.display = 'none';
          }
        }, 100);
      }
    }

    // タイピング文章セット
    function wordSet() {
      if (count == limit) {
        finish();
      } else {
        example.innerHTML = '<div>' + wordJP1[ridx[count]] + '</div>';
        kana.innerHTML = '<div>' + wordJP2[ridx[count]] + '</div>';
        wordR = wordRs[ridx[count]];
        let html;
        html = '<div><span class="typed"></span><span>';
        for (let i = 0; i < wordR.length; i++) {
          html += wordR[i][0];
        }
        html += '</span></div>';
        sentence.innerHTML = html;
        pattern = new Array(wordR.length).fill(0);
        if (count > 0) {
          progress.style.transform = 'scaleX(' + (1 - count / limit) + ')';
        }
        count++;
        selActive();
      }
    }

    // ゲーム終了
    function finish() {
      let time = new Date() - begin;
      playing = false;

      const active = keyboard.querySelector('.active');
      if (active) active.classList.remove('active');

      view2.style.display = 'none';
      result.style.display = 'block';
      example.innerHTML = '';
      kana.innerHTML = '';
      sentence.innerHTML = '';
      progress.style.transform = 'none';

      const resList = document.getElementById('example-list');
      const resData = result.querySelectorAll('.result-data');

      let speed, accuracy, score;
      speed = correct / time * 60 * 1000;
      accuracy = correct / (correct + miss);
      score = isStopped ? '-' : Math.floor(speed * accuracy ** 3);

      let html;
      html = '<ul>';
      for (let i = 0; i < limit; i++) {
        html += '<li>';
        html += '<div class="example">' + wordJP1[ridx[i]] + '</div>';
        html += '<div class="sentence">';
        wordR = wordRs[ridx[i]];
        if (isStopped) {
          if (record[i]) {
            html += record[i];
          } else {
            if (!!recordHTML) {
              html += recordHTML;
              recordHTML = '';
              if (missFlag) {
                weakKeys.push(wordR[idx1][pattern[idx1]][idx2]);
                html += '<span class="miss">' + wordR[idx1][pattern[idx1]][idx2] + '</span>';
                missFlag = false;
              } else {
                html += wordR[idx1][pattern[idx1]][idx2];
              }
              for (let j = idx2 + 1; j < wordR[idx1][pattern[idx1]].length; j++) {
                html += wordR[idx1][pattern[idx1]][j];
              }
              for (let j = idx1 + 1; j < wordR.length; j++) {
                html += wordR[j][0];
              }
            } else {
              if (missFlag) {
                weakKeys.push(wordR[0][0][0]);
                html += '<span class="miss">' + wordR[0][0][0] + '</span>';
                for (let j = 1; j < wordR[0][0].length; j++) {
                  html += wordR[0][0][j];
                }
                for (let j = 1; j < wordR.length; j++) {
                  html += wordR[j][0];
                }
                missFlag = false;
              } else {
                for (let j = 0; j < wordR.length; j++) {
                  html += wordR[j][0];
                }
              }
            }
          }
        } else {
          html += record[i];
        }
        html += '</div></li>';
      }
      html += '</ul>';
      resList.innerHTML = html;

      if (gameData.length > 0) {
        html = '<ul>'
        html += '<li><div class="data">' + gameData[0] + '</div></li>';
        html += '<li><div class="data">' + gameData[1] + '</div></li>';
        html += '<li><div class="data">' + gameData[2] + '</div></li>';
        html += '<li><div class="data">' + gameData[3] + '</div></li>';
        html += '<li><div class="data">' + gameData[4] + '</div></li>';
        html += '<li><div class="data">' + gameData[5] + '</div></li>';
        html += '<li><div class="data">' + gameData[6] + '</div></li>';
        html += '<li><div class="data">' + gameData[7] + '</div></li>';
        html += '</ul>';
      } else {
        html = '<ul>'
        html += '<li><div class="data">-</div></li>';
        html += '<li><div class="data">-</div></li>';
        html += '<li><div class="data">-</div></li>';
        html += '<li><div class="data">-</div></li>';
        html += '<li><div class="data">-</div></li>';
        html += '<li><div class="data">-</div></li>';
        html += '<li><div class="data">-</div></li>';
        html += '<li><div class="data">-</div></li>';
        html += '</ul>';
      }
      resData[1].innerHTML = html;

      html = '<ul>';
      html += '<li><div class="title">スコア</div><div class="data">' + score + '</div></li>';
      html += '<li><div class="title">レベル</div><div class="data">' + getLevel(score) + '</div></li>';
      html += '<li><div class="title">入力時間</div><div class="data">' + convTime(time) + '</div></li>';
      html += '<li><div class="title">入力文字数</div><div class="data">' + correct + '</div></li>';
      html += '<li><div class="title">ミス入力数</div><div class="data">' + miss + '</div></li>';
      html += '<li><div class="title">WPM</div><div class="data">' + convStr(speed.toFixed(2)) + '</div></li>';
      html += '<li><div class="title">正確率</div><div class="data">' + convStr((accuracy * 100).toFixed(2)) + '%</div></li>';
      html += '<li><div class="title">苦手キー</div><div class="data">' + getWeaks(weakKeys) + '</div></li>';
      html += '</ul>';
      resData[0].innerHTML = html;
      gameData = [score, getLevel(score), convTime(time), correct, miss, convStr(speed.toFixed(2)), convStr((accuracy * 100).toFixed(2)) + '%', getWeaks(weakKeys)];

      if (resCmt) {
        const resComment = document.getElementById('result-comment');
        const container = resComment.querySelector('.container');
        const comment1 = 'ノーミス達成!おめでとうございます。';
        const comment2 = '惜しい!あと1文字。次はミス0を狙いましょう。';
        const comments = ['日々の練習が結果に繋がります。', '速さよりも正確性のほうがスコアに響きます。'];
        if (!isStopped) {
          if (miss == 0) {
            container.innerHTML = comment1;
          } else if (miss == 1) {
            container.innerHTML = comment2;
          } else {
            let idx = Math.floor(Math.random() * comments.length);
            container.innerHTML = comments[idx];
          }
        } else {
          container.innerHTML = '---';
        }
      }

      isStopped = false;

      const moBtn = document.getElementById('miss-only-button');
      if (recordM.length > 0) {
        if (!moBtn) {
          const button6 = document.createElement('button');
          button6.type = 'button';
          button6.id = 'miss-only-button';
          button6.classList.add('btn');
          button6.innerHTML = 'ミスだけ';
          button6.addEventListener('click', () => {
            moPlay = true;
            sWait = true;
            result.style.display = 'none';
            view2.style.display = 'block';
            startMsg.style.display = 'block';
            space.classList.add('active');
          });
          const btnArea = document.getElementById('btn-area');
          btnArea.appendChild(button6);
        }
      } else {
        if (moBtn) {
          moPlay = false;
          moBtn.remove();
        }
      }
    }

    // かな->ローマ変換
    function kanaToRoman(kana) {
      const romanMap = {
        'あ': ['a'], 'い': ['i', 'yi'], 'う': ['u', 'wu'], 'え': ['e'], 'お': ['o'],
        'か': ['ka', 'ca'], 'き': ['ki'], 'く': ['ku', 'cu', 'qu'], 'け': ['ke'], 'こ': ['ko', 'co'],
        'さ': ['sa'], 'し': ['si', 'shi', 'ci'], 'す': ['su'], 'せ': ['se', 'ce'], 'そ': ['so'],
        'た': ['ta'], 'ち': ['ti', 'chi'], 'つ': ['tu', 'tsu'], 'て': ['te'], 'と': ['to'],
        'な': ['na'], 'に': ['ni'], 'ぬ': ['nu'], 'ね': ['ne'], 'の': ['no'],
        'は': ['ha'], 'ひ': ['hi'], 'ふ': ['fu', 'hu'], 'へ': ['he'], 'ほ': ['ho'],
        'ま': ['ma'], 'み': ['mi'], 'む': ['mu'], 'め': ['me'], 'も': ['mo'],
        'や': ['ya'], 'ゆ': ['yu'], 'よ': ['yo'],
        'ら': ['ra'], 'り': ['ri'], 'る': ['ru'], 'れ': ['re'], 'ろ': ['ro'],
        'わ': ['wa'], 'ゐ': ['wyi'], 'ゑ': ['wye'], 'を': ['wo'], 'ん': ['nn', 'xn', 'n'],
        'が': ['ga'], 'ぎ': ['gi'], 'ぐ': ['gu'], 'げ': ['ge'], 'ご': ['go'],
        'ざ': ['za'], 'じ': ['ji', 'zi'], 'ず': ['zu'], 'ぜ': ['ze'], 'ぞ': ['zo'],
        'だ': ['da'], 'ぢ': ['di'], 'づ': ['du'], 'で': ['de'], 'ど': ['do'],
        'ば': ['ba'], 'び': ['bi'], 'ぶ': ['bu'], 'べ': ['be'], 'ぼ': ['bo'],
        'ぱ': ['pa'], 'ぴ': ['pi'], 'ぷ': ['pu'], 'ぺ': ['pe'], 'ぽ': ['po'],
        'うぁ': ['wha'], 'うぃ': ['whi'], 'うぇ': ['whe'], 'うぉ': ['who'],
        'きゃ': ['kya'], 'きぃ': ['kyi'], 'きゅ': ['kyu'], 'きぇ': ['kye'], 'きょ': ['kyo'],
        'くぁ': ['qa', 'qwa'], 'くぃ': ['qi', 'qwi'], 'くぇ': ['qe', 'qwe'], 'くぉ': ['qo', 'qwo'], 'くゃ': ['qya'], 'くゅ': ['qyu'], 'くょ': ['qyo'],
        'しゃ': ['sya', 'sha'], 'しぃ': ['syi'], 'しゅ': ['syu', 'shu'], 'しぇ': ['sye', 'she'], 'しょ': ['syo', 'sho'],
        'つぁ': ['tsa'], 'つぃ': ['tsi'], 'つぇ': ['tse'], 'つぉ': ['tso'],
        'ちゃ': ['tya', 'cha'], 'ちぃ': ['tyi'], 'ちゅ': ['tyu', 'chu'], 'ちぇ': ['tye', 'che'], 'ちょ': ['tyo', 'cho'],
        'てゃ': ['tha'], 'てぃ': ['thi'], 'てゅ': ['thu'], 'てぇ': ['the'], 'てょ': ['tho'],
        'とぁ': ['twa'], 'とぃ': ['twi'], 'とぅ': ['twu'], 'とぇ': ['twe'], 'とぉ': ['two'],
        'ひゃ': ['hya'], 'ひぃ': ['hyi'], 'ひゅ': ['hyu'], 'ひぇ': ['hye'], 'ひょ': ['hyo'],
        'ふぁ': ['fa'], 'ふぃ': ['fi'], 'ふぇ': ['fe'], 'ふぉ': ['fo'],
        'にゃ': ['nya'], 'にぃ': ['nyi'], 'にゅ': ['nyu'], 'にぇ': ['nye'], 'にょ': ['nyo'],
        'みゃ': ['mya'], 'みぃ': ['myi'], 'みゅ': ['myu'], 'みぇ': ['mye'], 'みょ': ['myo'],
        'りゃ': ['rya'], 'りぃ': ['ryi'], 'りゅ': ['ryu'], 'りぇ': ['rye'], 'りょ': ['ryo'],
        'ヴぁ': ['va'], 'ヴぃ': ['vi'], 'ヴ': ['vu'], 'ヴぇ': ['ve'], 'ヴぉ': ['vo'],
        'ぎゃ': ['gya'], 'ぎぃ': ['gyi'], 'ぎゅ': ['gyu'], 'ぎぇ': ['gye'], 'ぎょ': ['gyo'],
        'ぐぁ': ['gwa'], 'ぐぃ': ['gwi'], 'ぐぅ': ['gwu'], 'ぐぇ': ['gwe'], 'ぐぉ': ['gwo'],
        'じゃ': ['ja', 'zya'], 'じぃ': ['jyi', 'zyi'], 'じゅ': ['ju', 'zyu'], 'じぇ': ['je', 'zye'], 'じょ': ['jo', 'zyo'],
        'でゃ': ['dha'], 'でぃ': ['dhi'], 'でゅ': ['dhu'], 'でぇ': ['dhe'], 'でょ': ['dho'],
        'ぢゃ': ['dya'], 'ぢぃ': ['dyi'], 'ぢゅ': ['dyu'], 'ぢぇ': ['dye'], 'ぢょ': ['dyo'],
        'びゃ': ['bya'], 'びぃ': ['byi'], 'びゅ': ['byu'], 'びぇ': ['bye'], 'びょ': ['byo'],
        'ぴゃ': ['pya'], 'ぴぃ': ['pyi'], 'ぴゅ': ['pyu'], 'ぴぇ': ['pye'], 'ぴょ': ['pyo'],
        'ぁ': ['la', 'xa'], 'ぃ': ['li', 'xi'], 'ぅ': ['lu', 'xu'], 'ぇ': ['le', 'xe'], 'ぉ': ['lo', 'xo'],
        'ゃ': ['lya', 'xya'], 'ゅ': ['lyu', 'xyu'], 'ょ': ['lyo', 'xyo'], 'っ': ['ltu', 'xtu'],
        'ー': ['-'], ',': [','], '.': ['.'], '、': [','], '。': ['.'],
        '・': ['/'], '、': [','], '。': ['.'], '・': ['/']
      };

      let remStr = String(kana), slStr, roman, next;
      let result = [];

      function splice() {
        let oneChar = remStr.slice(0, 1);
        remStr = remStr.slice(1);
        return oneChar;
      }

      function isSmallChar() {
        return !!remStr.slice(0, 1).match(/^[ぁぃぅぇぉゃゅょ]$/);
      }

      while (remStr) {
        slStr = splice();
        next = romanMap[remStr.slice(0, 1)];
        if (slStr == 'っ') {
          if (!remStr || remStr.match(/^[,.]/) || !next || next[0].match(/^[aiueon]/)) {
            roman = [...romanMap[slStr]];
            result.push(roman);
          } else {
            slStr = splice();
            if (isSmallChar()) slStr += splice();
            roman = [...romanMap[slStr].map(str => str.slice(0, 1) + str)];
            result.push(roman);
          }
        } else {
          if (isSmallChar()) slStr += splice();
          if (slStr == '&') {
            slStr += remStr.slice(0, 7);
            remStr = remStr.slice(7);
          }
          roman = romanMap[slStr] ? [...romanMap[slStr]] : [...slStr];
          if (slStr == 'ん') {
            if (!remStr) {
              roman.pop();
            } else {
              if (next[0].match(/^[aiueony]/)) roman.pop();
            }
          }
          result.push(roman);
        }
      }

      return result;
    }

    // 打った部分の色付け
    function colorTyped() {
      let html = '<div><span class="typed">';
      if (idx1 > 0) {
        for (let i = 0; i < idx1; i++) {
          html += wordR[i][pattern[i]];
        }
      }
      for (let i = 0; i <= idx2; i++) {
        html += wordR[idx1][pattern[idx1]][i];
      }
      html += '</span><span>';
      for (let i = idx2 + 1; i < wordR[idx1][pattern[idx1]].length; i++) {
        html += wordR[idx1][pattern[idx1]][i];
      }
      for (let i = idx1 + 1; i < wordR.length; i++) {
        html += wordR[i][pattern[i]];
      }
      html += '</span></div>';
      return html;
    }

    // テキスト移動処理
    function textMove() {
      const textS = document.querySelector('#sentence > div');
      const textE = document.querySelector('#example > div');
      const textK = document.querySelector('#kana > div');
      const textS1 = textS.querySelector('.typed');
      const textS2 = textS.querySelector('span:not(.typed)');
      let remLen = textS2.innerText.length;
      if (idx1 == 0) {
        over1 = textS.clientWidth - 580;
        over2 = textE.clientWidth - 580;
        over3 = textK.clientWidth - 580;
        left1 = 0, left2 = 0, left3 = 0;
      }
      if (textS.clientWidth > 580) {
        if (textS1.getBoundingClientRect().width > 310) {
          let move1 = textS2.getBoundingClientRect().width / remLen;
          left1 += move1;
          textS.style.left = -left1 + 'px';
        }
      }
      if (textE.clientWidth > 580) {
        let move2 = over2 / remLen;
        left2 += move2;
        textE.style.left = -left2 + 'px';
        over2 -= move2;
      }
      if (textK.clientWidth > 580) {
        let move3 = over3 / remLen;
        left3 += move3;
        textK.style.left = -left3 + 'px';
        over3 -= move3;
      }
    }

    // ミス入力処理
    function missed() {
      miss++;
      if (recordM.indexOf(ridx[count - 1]) == -1) {
        recordM.push(ridx[count - 1]);
      }
      mScreen.classList.add('missed');
      setTimeout(() => {
        mScreen.classList.remove('missed');
      }, 200);
    }

    // アクティブキー処理
    function selActive() {
      const prevActive = keyboard.querySelector('.active');
      const selector = '.key_' + keyConvert(wordR[idx1][pattern[idx1]][idx2]);
      const target = keyboard.querySelector(selector);
      if (prevActive) {
        prevActive.classList.remove('active');
      }
      if (target && flagG) {
        target.classList.add('active');
      }
    }

    // 対応キーの変換
    function keyConvert(key) {
      const keyMap = {
        '-': 'hyphen', '@': 'atmark', ';': 'semicolon', ':': 'colon', ',': 'comma',
        '.': 'period', '/': 'slash', ' ': 'space'
      }

      if (keyMap[key]) {
        return keyMap[key];
      } else {
        return key;
      }
    }

    // タイピングレベル判定
    function getLevel(score) {
      let level;
      if (score == '-') {
        level = '-';
      } else {
        switch (true) {
          case 0 <= score && score <= 21:
            level = 'E-';
            break;
          case 21 < score && score <= 38:
            level = 'E';
            break;
          case 38 < score && score <= 55:
            level = 'E+';
            break;
          case 55 < score && score <= 72:
            level = 'D-';
            break;
          case 72 < score && score <= 89:
            level = 'D';
            break;
          case 89 < score && score <= 106:
            level = 'D+';
            break;
          case 106 < score && score <= 123:
            level = 'C-';
            break;
          case 123 < score && score <= 140:
            level = 'C';
            break;
          case 140 < score && score <= 157:
            level = 'C+';
            break;
          case 157 < score && score <= 174:
            level = 'B-';
            break;
          case 174 < score && score <= 191:
            level = 'B';
            break;
          case 191 < score && score <= 208:
            level = 'B+';
            break;
          case 208 < score && score <= 225:
            level = 'A-';
            break;
          case 225 < score && score <= 242:
            level = 'A';
            break;
          case 242 < score && score <= 259:
            level = 'A+';
            break;
          case 259 < score && score <= 276:
            level = 'S';
            break;
          case 276 < score && score <= 299:
            level = 'Good!';
            break;
          case 299 < score && score <= 324:
            level = 'Fast';
            break;
          case 324 < score && score <= 349:
            level = 'Thunder';
            break;
          case 349 < score && score <= 374:
            level = 'Ninja';
            break;
          case 374 < score && score <= 399:
            level = 'Comet';
            break;
          case 399 < score && score <= 449:
            level = 'Professor';
            break;
          case 449 < score && score <= 499:
            level = 'LaserBeam';
            break;
          case 499 < score && score <= 549:
            level = 'EddieVH';
            break;
          case 549 < score && score <= 599:
            level = 'Meijin';
            break;
          case 599 < score && score <= 649:
            level = 'Rocket';
            break;
          case 649 < score && score <= 699:
            level = 'Tatsujin';
            break;
          case 699 < score && score <= 749:
            level = 'Jedi';
            break;
          case 749 < score && score <= 799:
            level = 'Godhand';
            break;
          case 799 < score:
            level = 'Joker';
            break;
        }
      }
      return level;
    }

    // 苦手キー上位5個を取得
    function getWeaks(keys) {
      let keyData1 = {};
      keys.forEach((key) => {
        if (keyData1[key] != undefined) {
          keyData1[key] += 1;
        } else {
          keyData1[key] = 1;
        }
      });
      let keyData2 = Object.keys(keyData1).map(k => ({ key: k, miss: keyData1[k] }));
      keyData2.sort((a, b) => b.miss - a.miss);

      let res = '';
      let max = (keyData2.length < 5) ? keyData2.length : 5;
      for (let i = 0; i < max; i++) {
        if (i != max - 1) {
          res += keyData2[i].key + ' ';
        } else {
          res += keyData2[i].key;
        }
      }
      return res;
    }

    // 入力時間の変換
    function convTime(time) {
      let m, ms, s, res;
      if (time >= 60000) {
        m = Math.floor(time / 60000);
        ms = time - m * 60000;
        s = (ms / 1000).toFixed(2);
        res = m + '分' + s.slice(0, -3) + '秒' + s.slice(-2);
      } else {
        s = (time / 1000).toFixed(2);
        res = s.slice(0, -3) + '秒' + s.slice(-2);
      }
      return res;
    }

    // 文字列データの変換
    function convStr(str) {
      let res;
      if (str == 'NaN') {
        res = '0';
      } else {
        if (str.slice(-2) == '00') {
          res = str.slice(0, -3);
        } else {
          res = str;
        }
      }
      return res;
    }

    // スイッチ処理
    function toggle(idx, flag) {
      if (flag) {
        onBtns[idx].click();
      } else {
        offBtns[idx].click();
      }
    }

    // リプレイ処理
    function replay() {
      moPlay = false;
      result.style.display = 'none';
      view2.style.display = 'block';
      startMsg.style.display = 'block';

      sWait = true;
      space.classList.add('active');
    }

    // クローズ処理
    function close() {
      isOpen = false;
      startFlag = false;
      playing = false;
      nFlag = false;
      missFlag = false;
      moPlay = false;
      gameData = [];

      const active = keyboard.querySelector('.active');
      if (active) active.classList.remove('active');

      view2.style.display = 'none';
      result.style.display = 'none';
      example.innerHTML = '';
      kana.innerHTML = '';
      sentence.innerHTML = '';
      progress.style.transform = 'none';
      overlay.style.display = 'none';
      game.style.display = 'none';
    }

    // ボタンクリック時
    button1.addEventListener('click', close);
    button2.addEventListener('click', open);
    button3.addEventListener('click', start);
    button4.addEventListener('click', replay);
    button5.addEventListener('click', close);
    for (let i = 0; i < onBtns.length; i++) {
      onBtns[i].addEventListener('click', () => {
        onBtns[i].classList.remove('show');
        offBtns[i].classList.add('show');
        flags[i] = false;
      });
    }
    for (let i = 0; i < offBtns.length; i++) {
      offBtns[i].addEventListener('click', () => {
        offBtns[i].classList.remove('show');
        onBtns[i].classList.add('show');
        flags[i] = true;
      });
    }

    // キー押下時
    window.addEventListener('keydown', (event) => {
      let key = event.key;
      if (isOpen && !startFlag) {
        if (key == ' ') event.preventDefault();
        if (key == 'r') toggle(0, flags[0]);
        if (key == 'k') toggle(1, flags[1]);
        if (key == 'g') toggle(2, flags[2]);
        if (key == 'w') toggle(3, flags[3]);
        if (key == 's') toggle(4, flags[4]);
      }
      if (startFlag) { // ゲーム開始
        if (key == ' ') event.preventDefault();
        if (sWait) { // スペースキー入力待ちの場合
          if (key == ' ') {
            sWait = false;
            space.classList.remove('active');
            ready();
          }
        }
        if (playing) { // プレイ中
          if (key == 'Escape') { // Escを押した場合
            isStopped = true;
            finish();
          } else {
            temp += key;
            if (key == wordR[idx1][pattern[idx1]][idx2]) {
              sentence.innerHTML = colorTyped();
              if (missFlag) {
                recordHTML += '<span class="miss">' + key + '</span>';
                weakKeys.push(key);
                missFlag = false;
              } else {
                recordHTML += key;
              }
              textMove();
              correct++;
              idx2++;
            } else {
              let reg = new RegExp('^' + temp);
              for (let i = 0; i < wordR[idx1].length; i++) {
                if (!!wordR[idx1][i].match(reg)) {
                  pattern[idx1] = i;
                  break;
                }
              }
              if (key == wordR[idx1][pattern[idx1]][idx2]) {
                sentence.innerHTML = colorTyped();
                if (missFlag) {
                  recordHTML += '<span class="miss">' + key + '</span>';
                  weakKeys.push(key);
                  missFlag = false;
                } else {
                  recordHTML += key;
                }
                textMove();
                correct++;
                idx2++;
              } else {
                if (wordR[idx1][pattern[idx1]] == 'nn' && wordR[idx1].length == 3) { // 「ん」の特別措置
                  for (let i = 0; i < wordR[idx1 + 1].length; i++) {
                    if (key == wordR[idx1 + 1][i][0]) {
                      pattern[idx1] = 2;
                      pattern[idx1 + 1] = i;
                      nFlag = true;
                      correct++;
                      break;
                    }
                  }
                  if (!nFlag) {
                    missFlag = true;
                    temp = temp.slice(0, -1);
                    missed();
                  }
                } else {
                  missFlag = true;
                  temp = temp.slice(0, -1);
                  missed();
                }
              }
            }
            if (idx2 == wordR[idx1][pattern[idx1]].length) {
              idx1++;
              if (nFlag) {
                idx2 = 0;
                sentence.innerHTML = colorTyped();
                textMove();
                if (missFlag) {
                  recordHTML += '<span class="miss">' + key + '</span>';
                  weakKeys.push(key);
                  missFlag = false;
                } else {
                  recordHTML += key;
                }
                idx2 = 1;
                nFlag = false;
                temp = temp.slice(1);
              } else {
                idx2 = 0;
                temp = '';
              }
            }
            if (idx1 == wordR.length) {
              record.push(recordHTML);
              recordHTML = '';
              idx1 = 0;
              wordSet();
            } else {
              if (!missFlag) selActive();
            }
          }
        }
      }
    });

    // リサイズ時
    window.addEventListener('resize', () => {
      if (isOpen) {
        overlay.style.width = document.body.clientWidth + 'px';
        overlay.style.height = document.body.clientHeight + 'px';
        game.style.top = window.pageYOffset + window.innerHeight / 2 - game.clientHeight / 2 + 'px';
        game.style.left = window.innerWidth / 2 - game.clientWidth / 2 + 'px';
      }
    });
  })(window, document);
</script>
<style>
  #typing-start {
    font-family: Meiryo, Arial, sans-serif;
    text-align: center;
    padding: 20px 6px 5px;
    background-color: #fff2d8;
    border: 1px solid #ffd687;
    border-radius: 3px;
  }

  #typing-start p {
    font-size: 15px;
    margin: 0 0 15px;
  }

  #typing-start p+p {
    margin: 0;
  }

  #typing-start .note {
    font-size: 14px;
  }

  #typing-start .msg {
    color: tomato;
  }

  #open-button {
    display: block;
    width: 308px;
    height: 48px;
    font-size: 17px;
    font-weight: bold;
    color: #fff;
    background: linear-gradient(0deg, #ffb500, #ffcf00);
    border: 0;
    border-radius: 3em;
    margin: 20px auto 30px;
    box-shadow: 0 1px 1px rgba(0, 0, 0, .2);
    overflow: hidden;
    cursor: pointer;
    appearance: none;
  }

  #game-overlay {
    position: absolute;
    top: 0;
    left: 0;
    background: #000;
    opacity: .8;
    z-index: 9999;
  }

  #game-screen {
    display: none;
    position: absolute;
    width: 748px;
    background: #fff;
    padding: 0 10px 10px;
    z-index: 10000;
  }

  #game-screen,
  #game-screen * {
    font-family: Meiryo, Arial, sans-serif;
    box-sizing: border-box;
  }

  #game-screen ul {
    list-style-type: none;
    padding: 0;
    margin: 0;
  }

  #game-header {
    position: relative;
    height: 40px;
    border-bottom: 1px solid #cacaca;
    margin: 0 0 5px;
  }

  #close-button1 {
    position: absolute;
    top: 6px;
    right: 0;
    width: 63px;
    height: 27px;
    font-size: 11px;
    font-weight: bold;
    color: #777;
    background: linear-gradient(0deg, #eee, #fff);
    padding: 1px 5px;
    border: 1px solid #ccc;
    border-radius: 3px;
    cursor: pointer;
    appearance: none;
  }

  #close-button1:before {
    content: "";
    display: inline-block;
    width: 12px;
    height: 12px;
    background-image: url('data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z" fill="%23777"/></svg>');
    background-size: contain;
    background-repeat: no-repeat;
    margin-right: 2px;
    vertical-align: -0.2em;
  }

  #close-button1:hover {
    background: linear-gradient(180deg, #eee, #fff);
  }

  #game-header .description {
    height: 40px;
    font-size: 18px;
    color: #7b7a7a;
    line-height: 40px;
    margin: 0 0 0 5px;
  }

  #game-banner {
    width: 728px;
    height: 90px;
    margin: 0 auto 5px;
  }

  #game-banner a {
    display: block;
    width: 100%;
    height: 100%;
  }

  #game-body {
    display: table;
    width: 728px;
    height: 470px;
    color: #636363;
  }

  #game-view1 {
    position: relative;
    display: table-cell;
    text-align: center;
    vertical-align: middle;
  }

  #game-title {
    font-size: 24px;
  }

  #game-explain {
    font-size: 15px;
    padding: 0 24px;
    margin: 12px 0 74px;
  }

  #start-button {
    width: 160px;
    height: 45px;
    line-height: 45px;
    text-align: center;
    color: #fff;
    font-size: 14px;
    font-weight: bold;
    background: #057fff;
    border: 0;
    border-radius: 3px;
    margin: 0 auto;
    cursor: pointer;
    appearance: none;
  }

  #game-func {
    position: absolute;
    bottom: 0;
    left: 50%;
    transform: translateX(-50%);
    height: 28px;
    line-height: 26px;
    background-color: #f3f3f3;
    padding: 0 12px;
    border-radius: 3px;
    border: 1px solid #d9d9d9;
    white-space: nowrap;
  }

  #game-func>span {
    display: inline-block;
    font-size: 11px;
  }

  #game-func .switch-btn {
    display: inline-block;
    position: relative;
    width: 36px;
    height: 16px;
    vertical-align: middle;
    margin-right: 6px;
    overflow: hidden;
  }

  #game-func .switch-btn:last-child {
    margin-right: 0;
  }

  #game-func .switch-btn .btn {
    position: absolute;
    display: none;
    top: 0;
    left: 0;
    width: 36px;
    height: 16px;
    line-height: 15px;
    letter-spacing: 0.5px;
    font-size: 11px;
    color: #fff;
    text-align: center;
    border: 0;
    border-radius: 3px;
    overflow: hidden;
    cursor: pointer;
    appearance: none;
  }

  #game-func .switch-btn .on-btn {
    background-color: #2a89ff;
  }

  #game-func .switch-btn .off-btn {
    background-color: #ff4032;
  }

  .show {
    display: block !important;
  }

  #game-view2 {
    display: none;
  }

  #text-container {
    position: relative;
    max-width: 610px;
    height: 172px;
    border: 1px solid #d8d8d8;
    margin: 0 auto 10px;
    overflow: hidden;
  }

  #miss-type-screen {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(255, 0, 0, 0.2);
    opacity: 0;
  }

  #miss-type-screen.missed {
    animation: miss .2s;
  }

  #start-msg {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    font-size: 14px;
    text-align: center;
    line-height: 170%;
    padding-top: 46px;
  }

  #start-msg p {
    padding: 0;
    margin: 0 0 4px;
  }

  #start-msg em {
    font-size: 24px;
    font-style: normal;
    color: #ff9c00;
  }

  #countdown {
    position: absolute;
    top: 50%;
    left: 50%;
    color: #ff9c00;
    font-size: 80px;
    transform: translateX(-50%) translateY(-50%);
  }

  #example {
    position: absolute;
    left: 15px;
    top: 36px;
    width: 580px;
    height: 36px;
    font-size: 30px;
    line-height: 36px;
    overflow: hidden;
    font-variant-ligatures: no-common-ligatures;
  }

  #example>div {
    position: absolute;
    top: 0;
    left: 0;
    height: 30px;
    white-space: nowrap;
  }

  #kana {
    position: absolute;
    left: 15px;
    top: 73px;
    width: 580px;
    height: 24px;
    font-size: 20px;
    line-height: 24px;
    overflow: hidden;
    font-variant-ligatures: no-common-ligatures;
  }

  #kana>div {
    position: absolute;
    top: 0;
    left: 0;
    height: 20px;
    white-space: nowrap;
  }

  #sentence {
    position: absolute;
    left: 15px;
    top: 102px;
    width: 580px;
    height: 30px;
    font-size: 26px;
    line-height: 26px;
    overflow: hidden;
    text-transform: uppercase;
    font-variant-ligatures: no-common-ligatures;
  }

  #sentence>div {
    position: absolute;
    top: 0;
    left: 0;
    height: 30px;
    white-space: nowrap;
  }

  #sentence .typed {
    color: #ffd0a6;
  }

  #progress-bar {
    position: absolute;
    bottom: 1px;
    left: 5px;
    width: calc(100% - 10px);
    height: 4px;
    background-color: #bcbcbc;
    transform-origin: left top;
  }

  #current-wpm {
    display: none;
    position: absolute;
    top: 5px;
    right: 10px;
  }

  #speed-bar {
    display: none;
    position: absolute;
    top: 2px;
    left: 5px;
    width: calc(100% - 10px);
    height: 3px;
    background: linear-gradient(90deg, #87cefa, #ff6347);
    transform-origin: left top;
  }

  .cover {
    top: 0;
    right: 0;
    height: 3px;
    background: #fff;
    transform-origin: right top;
  }

  #virtual-keyboard {
    position: relative;
    width: 610px;
    margin: 0 auto;
  }

  #virtual-keyboard div {
    position: absolute;
    width: 36px;
    height: 36px;
    border: 1px solid #d8d8d8;
    border-radius: 3px;
    font-weight: bold;
    font-size: 16px;
    line-height: 34px;
    text-align: center;
    overflow: hidden;
  }

  #virtual-keyboard div.key_1 {
    top: 0;
    left: 41px;
  }

  #virtual-keyboard div.key_2 {
    top: 0;
    left: 82px;
  }

  #virtual-keyboard div.key_3 {
    top: 0;
    left: 123px;
  }

  #virtual-keyboard div.key_4 {
    top: 0;
    left: 164px;
  }

  #virtual-keyboard div.key_5 {
    top: 0;
    left: 205px;
  }

  #virtual-keyboard div.key_6 {
    top: 0;
    left: 246px;
  }

  #virtual-keyboard div.key_7 {
    top: 0;
    left: 287px;
  }

  #virtual-keyboard div.key_8 {
    top: 0;
    left: 328px;
  }

  #virtual-keyboard div.key_9 {
    top: 0;
    left: 369px;
  }

  #virtual-keyboard div.key_0 {
    top: 0;
    left: 410px;
  }

  #virtual-keyboard div.key_hyphen {
    top: 0;
    left: 451px;
  }

  #virtual-keyboard div.deco_key2 {
    top: 0;
    left: 492px;
  }

  #virtual-keyboard div.deco_key3 {
    top: 0;
    left: 533px;
  }

  #virtual-keyboard div.deco_key4 {
    top: 0;
    left: 574px;
  }

  #virtual-keyboard div.deco_key5 {
    top: 41px;
    width: 56px;
  }

  #virtual-keyboard div.key_q {
    top: 41px;
    left: 61px;
  }

  #virtual-keyboard div.key_w {
    top: 41px;
    left: 102px;
  }

  #virtual-keyboard div.key_e {
    top: 41px;
    left: 143px;
  }

  #virtual-keyboard div.key_r {
    top: 41px;
    left: 184px;
  }

  #virtual-keyboard div.key_t {
    top: 41px;
    left: 225px;
  }

  #virtual-keyboard div.key_y {
    top: 41px;
    left: 266px;
  }

  #virtual-keyboard div.key_u {
    top: 41px;
    left: 307px;
  }

  #virtual-keyboard div.key_i {
    top: 41px;
    left: 348px;
  }

  #virtual-keyboard div.key_o {
    top: 41px;
    left: 389px;
  }

  #virtual-keyboard div.key_p {
    top: 41px;
    left: 430px;
  }

  #virtual-keyboard div.key_atmark {
    top: 41px;
    left: 471px;
  }

  #virtual-keyboard div.deco_key6 {
    top: 41px;
    left: 512px;
  }

  #virtual-keyboard div.key_Enter {
    top: 41px;
    left: 553px;
    width: 57px;
    height: 77px;
    clip-path: polygon(0 0, 100% 0, 100% 100%, 21px 100%, 21px 36px, 0 36px);
  }

  #virtual-keyboard div.key_Enter:after {
    position: absolute;
    display: block;
    content: "";
    top: 34px;
    left: 0;
    width: 20px;
    height: 41px;
    border-top: 1px solid #d8d8d8;
    border-right: 1px solid #d8d8d8;
  }

  #virtual-keyboard div.deco_key7 {
    top: 82px;
    width: 76px;
  }

  #virtual-keyboard div.key_a {
    top: 82px;
    left: 81px;
  }

  #virtual-keyboard div.key_s {
    top: 82px;
    left: 122px;
  }

  #virtual-keyboard div.key_d {
    top: 82px;
    left: 163px;
  }

  #virtual-keyboard div.key_f {
    top: 82px;
    left: 204px;
  }

  #virtual-keyboard div.key_g {
    top: 82px;
    left: 245px;
  }

  #virtual-keyboard div.key_h {
    top: 82px;
    left: 286px;
  }

  #virtual-keyboard div.key_j {
    top: 82px;
    left: 327px;
  }

  #virtual-keyboard div.key_k {
    top: 82px;
    left: 368px;
  }

  #virtual-keyboard div.key_l {
    top: 82px;
    left: 409px;
  }

  #virtual-keyboard div.key_semicolon {
    top: 82px;
    left: 450px;
  }

  #virtual-keyboard div.key_colon {
    top: 82px;
    left: 491px;
  }

  #virtual-keyboard div.deco_key8 {
    top: 82px;
    left: 532px;
  }

  #virtual-keyboard div.key_lShift {
    top: 123px;
    left: 0;
    width: 96px;
  }

  #virtual-keyboard div.key_z {
    top: 123px;
    left: 101px;
  }

  #virtual-keyboard div.key_x {
    top: 123px;
    left: 142px;
  }

  #virtual-keyboard div.key_c {
    top: 123px;
    left: 183px;
  }

  #virtual-keyboard div.key_v {
    top: 123px;
    left: 224px;
  }

  #virtual-keyboard div.key_b {
    top: 123px;
    left: 265px;
  }

  #virtual-keyboard div.key_n {
    top: 123px;
    left: 306px;
  }

  #virtual-keyboard div.key_m {
    top: 123px;
    left: 347px;
  }

  #virtual-keyboard div.key_comma {
    top: 123px;
    left: 388px;
  }

  #virtual-keyboard div.key_period {
    top: 123px;
    left: 429px;
  }

  #virtual-keyboard div.key_slash {
    top: 123px;
    left: 470px;
  }

  #virtual-keyboard div.deco_key9 {
    top: 123px;
    left: 511px;
  }

  #virtual-keyboard div.key_rShift {
    top: 123px;
    left: 552px;
    width: 58px;
  }

  #virtual-keyboard div.deco_key10 {
    top: 164px;
    left: 0;
    width: 56px;
  }

  #virtual-keyboard div.deco_key11 {
    top: 164px;
    left: 61px;
  }

  #virtual-keyboard div.deco_key12 {
    top: 164px;
    left: 102px;
  }

  #virtual-keyboard div.deco_key13 {
    top: 164px;
    left: 143px;
  }

  #virtual-keyboard div.key_space {
    top: 164px;
    left: 184px;
    width: 181px;
  }

  #virtual-keyboard div.deco_key14 {
    top: 164px;
    left: 370px;
  }

  #virtual-keyboard div.deco_key15 {
    top: 164px;
    left: 411px;
  }

  #virtual-keyboard div.deco_key16 {
    top: 164px;
    left: 452px;
    width: 56px;
  }

  #virtual-keyboard div.deco_key17 {
    top: 164px;
    left: 513px;
  }

  #virtual-keyboard div.deco_key18 {
    top: 164px;
    left: 554px;
    width: 56px;
  }

  #virtual-keyboard div.active {
    background-color: #ff9c00 !important;
    border-color: #ff9c00 !important;
    color: #fff !important;
  }

  #game-result {
    display: none;
    width: 728px;
  }

  #current-result {
    position: relative;
    float: left;
    width: 590px;
    height: 337px;
    border: 1px solid #d0d0d0;
    margin: 0 0 12px;
  }

  #game-result .result-title {
    height: 37px;
    font-size: 15px;
    font-weight: bold;
    line-height: 37px;
    overflow: hidden;
    margin: 0;
    padding: 0 0 0 10px;
  }

  #current-result .result-title {
    color: #027fff;
    border-bottom: 1px solid #d0d0d0;
  }

  #example-list {
    position: absolute;
    top: 49px;
    left: 12px;
    width: 376px;
    height: 274px;
    padding: 10px;
    overflow: auto;
    border: 1px solid #d0d0d0;
  }

  #example-list ul {
    width: 354px;
  }

  #example-list ul li {
    font-size: 16px;
    margin-bottom: 12px;
    overflow-wrap: break-word;
  }

  #example-list .sentence {
    text-transform: uppercase;
  }

  #example-list .miss {
    color: #f00;
  }

  #game-result .result-data {
    position: absolute;
    width: 188px;
    height: 298px;
    top: 37px;
    right: 0;
    padding: 12px 11px 0 11px;
    background: #f7f7f7;
  }

  #game-result .result-data ul li {
    position: relative;
    border-bottom: 1px solid #d0d0d0;
  }

  #game-result .result-data ul li .title {
    position: absolute;
    font-size: 11px;
    height: 32px;
    line-height: 32px;
    top: 0;
    left: 0;
  }

  #game-result .result-data ul li .data {
    color: #027fff;
    font-size: 14px;
    height: 32px;
    line-height: 32px;
    width: 166px;
    text-align: right;
    font-weight: bold;
    padding-right: 6px;
  }

  #game-result .result-data ul li:last-child .data {
    text-transform: uppercase;
  }

  #prev-result {
    position: relative;
    width: 138px;
    height: 337px;
    float: right;
    border-top: 1px solid #d0d0d0;
    border-right: 1px solid #d0d0d0;
    border-bottom: 1px solid #d0d0d0;
  }

  #prev-result .result-title {
    border-bottom: 1px solid #d0d0d0;
  }

  #prev-result .result-data {
    width: 137px;
  }

  #prev-result .result-data ul li .data {
    width: 115px;
  }

  #result-comment {
    position: relative;
    width: 728px;
    height: 53px;
    text-align: center;
    border: 1px solid #d0d0d0;
    margin: 12px 0 0;
    clear: both;
  }

  #result-comment .container {
    position: absolute;
    width: 726px;
    font-size: 14px;
    top: 50%;
    left: 0;
    transform: translateY(-50%);
    padding: 0 18px;
  }

  #btn-area {
    position: relative;
    margin: 18px 0 0;
  }

  #game-result .btn {
    position: absolute;
    display: block;
    width: 129px;
    height: 39px;
    color: #fff;
    font-size: 14px;
    text-align: center;
    line-height: 39px;
    overflow: hidden;
    border-radius: 3px;
    cursor: pointer;
    border: 0;
    appearance: none;
  }

  #replay-button {
    top: 0;
    right: 289px;
    background-color: #027fff;
  }

  #close-button2 {
    top: 0;
    right: 0;
    color: #7b7a7a !important;
    background-color: #f4f5f5;
  }

  #miss-only-button {
    top: 0;
    left: 164px;
    background-color: #23c21f;
  }

  @keyframes miss {
    0% {
      opacity: 1;
    }

    100% {
      opacity: 0;
    }
  }
</style>
※追記(2023/05/14):「ん」を省略入力したときのバグを修正しました。

仕様

コードの仕様について簡単に説明します。

コードは上から順に本体のHTML, JavaScript, CSSで構成されており、<script> ~ </script>で囲まれた部分がJavaScriptです。JavaScriptコードを編集することで、入力文章を好きなものに変えたり設定を変更したりできます。

先に挙げておくと、JavaScript内でいじってOKな部分は以下の通りです。これら以外は(わかっている人を除き)基本的にいじらないでください。

パラメータ説明
wordJP1表示文章
wordJP2ひらがな文章
maxNum出題数の上限
random出題をランダムにするかどうか
resCmt結果画面のコメント表示有無

上記パラメータはソースコード内でもコメントを付けているので、探す際の参考にしてください。

編集時の注意

ローマ字を正しく生成できるよう、wordJP2は必ずひらがなで入力してください。数字や記号(0~9, @など)を入れる場合は、全角ではなく半角にしてください。

また、wordJP1wordJP2の文章は元コードのように配列内の位置をぴったり合わせてください。

let wordJP1 = ['いーあるふぁんくらぶ', '千本桜', ... , 'クワガタにチョップしたらタイムスリップした']; // 表示文章
let wordJP2 = ['いーあるふぁんくらぶ', 'せんぼんざくら', ... , 'くわがたにちょっぷしたらたいむすりっぷした']; // ひらがな文章

wordJP1[A, B, C]なのにwordJP2[C, A, B]のようになっている場合、表示がずれておかしな状態になってしまいます。

ローカル環境での遊び方

ローカル環境で遊ぶための手順は以下の通りです。

  1. ソースコードをコピーします。
  2. メモ帳を開き、コピーしたコードを貼り付けて.htmlファイルとして保存します。(名前はtyping-game.htmlなどお好きに)
    メモ帳のスクリーンショット
  3. 保存した.htmlファイルをダブルクリックするとブラウザが開き、ゲームをプレイできます。
    ゲームファイル画像

プログラム解説

一から十まで熱く語りたいところですが、全部やっていたらきりがないのでさっくり進めていきたいと思います。タイピングゲームの作成においてキーポイントとなる部分に絞って解説していきます。

全体構成

ゲーム本体のHTML、各種処理用のJavaScript、デザイン調整用のCSSという順で構成されています。

基本的なゲームの動作はビューを切り替えることで管理していて、操作によって特定の画面を表示したり非表示にしたり…という感じに。あとメインになるのがフラグ管理で、動作がおかしなことにならないように処理を制御しています。

管理するフラグが多くなってくると頭がこんがらがりますが、実際にプレイする時の状況を想像しながらトライ&エラーで地道に設定していきます。

スタート画面

JavaScript内のフラグの一部をスタート画面で設定できるようにしています。

スタート画面の設定画像

各ONボタンとOFFボタンにクリックイベントを登録して、クリックされた際にON/OFF表示とフラグが切り替わるようにしています。

for (let i = 0; i < onBtns.length; i++) {
  onBtns[i].addEventListener('click', () => {
    onBtns[i].classList.remove('show');
    offBtns[i].classList.add('show');
    flags[i] = false;
  });
}
for (let i = 0; i < offBtns.length; i++) {
  offBtns[i].addEventListener('click', () => {
    offBtns[i].classList.remove('show');
    onBtns[i].classList.add('show');
    flags[i] = true;
  });
}

項目名の直後に(R)など記号がありますが、実はキー操作でもON/OFFを切り替え可能です。こちらは対応するキーが押されたときに対応するボタンにクリック処理(.click())を行うことで実現しています。

各種フラグ設定は配列データとしてローカルストレージに保存されるようにしているので、ブラウザを閉じた場合も前回の設定がそのまま保持されます。

キーボード

なかなか大変だったのがキーボード。本家e-typingのキーボードを参考に作りましたが、たぶん数時間はかかったと思います。

キーボード画像

キーガイド機能を実現するために各キーと対応したクラス名の要素を作り、絶対位置配置で1つ1つ位置を調整しながらキーを配置していきました。

<div id="virtual-keyboard">
  <div class="deco_key1"></div>
  <div class="key_1">1</div>
  <div class="key_2">2</div>
  <div class="key_3">3</div>
  ...
  <div class="deco_key16"></div>
  <div class="deco_key17"></div>
  <div class="deco_key18"></div>
</div>

余談:Enterキーをclip-pathと疑似要素でいい感じに再現できたのが密かな喜びです。

キーガイド機能

selActiveという関数を作り、次に打つべき文字に対応するキーにactiveというクラス名を追加して色を付けるようにしています。

かな → ローマ変換

ここが日本語タイピングゲームを作るうえで一番の肝になります。

ソースコード中のkanaToRomanという関数がローマ字変換担当です。ひらがなデータを受け取り、対応するローマ字データを配列形式で返します。

例)「まとりょしか」の場合、[['ma'], ['to'], ['ryo'], ['si', 'shi', 'ci'], ['ka', 'ca']]という配列が返されます。

ローマ字変換処理を実装するにあたって、以下の記事を参考にさせて頂きました。こちらの記事の関数kanaToRomanをベースに、追加対応が必要な変換とデータ処理を加えています。

ひらがなからローマ字への変換は1通りではないので、各ひらがなとそれに対応するローマ字のパターンを格納した対応表(マップ)を用意し、それをもとにローマ字化処理を行います。

function kanaToRoman(kana) {
  const romanMap = {
    'あ':['a'], 'い':['i', 'yi'], 'う':['u', 'wu'], 'え':['e'], 'お':['o'],
    'か':['ka', 'ca'], 'き':['ki'], 'く':['ku', 'cu', 'qu'], 'け':['ke'], 'こ':['ko', 'co'],
    'さ':['sa'], 'し':['si', 'shi', 'ci'], 'す':['su'], 'せ':['se', 'ce'], 'そ':['so'],
    ...
    '・':['/'], '&#12289;':[','], '&#12290;':['.'], '&#12539;':['/']
  };
  ...
}

基本的には上の変換表をもとに1文字ずつ変換していきますが、通常の変換に加えて特別な処理が必要となる場合があります。それが以下のケース。

  • 促音(っ)
  • 後ろに拗音(ぁぃぅぇぉ)が続く場合
  • 「ん」が「n」で省略できない場合

まず促音の場合、通常は後ろに続く文字の1文字目を繰り返すことになる(例えば「っと」は「tto」)ので、「次の文字の1文字目+本体」という形で出力します。

ただし、「いっぬ(iltunu)」のように後ろにあ行・な行が来る場合や単体として処理する必要がある場合は、例外として促音を単体で処理します。

次に拗音が続く場合は、ひとまとまりで処理する必要があるので「現在の文字+次の拗音」というセットにし、それを変換表にかけて変換します。(例:「に」「ゃ」→「にゃ」)

「ん」の場合はタイピングの前準備として処理が必要で、文章の終わりか次の文字があ行・な行・や行の場合、省略入力ができないように['nn', 'xn', 'n']の最後の「n」を取り除きます。こうしておくことで、キー入力処理の際に確実に「ん」が省略入力可能かどうかを判定できます。

キー入力処理

キー押下時のイベントkeydownを利用して処理を行います。

文章タイピング時の処理に関しては、インデックスを利用した判定を行っています。文章のひらがなデータwordJP2からローマ字データwordRが作られるので、ローマ字データに対応したインデックスidx1idx2を用意します。

説明画像1

ローマ字データは配列が入れ子になっているので、上のようにidx1がひらがな1文字単位のインデックス、idx2がローマ字1文字単位のインデックスになっています。

これにより、例えば上の例では最初の文字と入力キーが合っているかどうかをkey == wordR[idx1][0][idx2]という形で判定できます。(実際はちょっと違います)

キー入力処理もずらっとコードが書かれていますが、上記の考え方を基本に必要な処理を加えていったらそうなっただけで、とりわけ複雑怪奇なことをしているわけではありません(^^;

結果画面

ゲーム中に取得したデータ(正解タイプ数、ミスタイプ数、経過時間など)から必要に応じて計算用のモジュールを作り、本家e-typingと同様に結果を表示する…というのが基本的な構成です。

WPM

WPMというのは入力速度を表す値で、たぶん words per minute の略です。1分間あたりのキー入力数を表すので、単純に1分間あたりの正解タイプ数を求めます。(JavaScriptの時間計測はミリ秒単位)

speed = correct / time * 60 * 1000;

余談:以前から気になっていたんですが、なぜWPMなのでしょうね。英文だと確かに単語単位なのでWPMですが、ローマ字タイピングの場合1文字単位なので、KPM (keys per minute) か CPM (characters per minute) のほうが個人的にはしっくりきます。

スコア

算出方法が謎だったe-typingのスコア。困って調べていると、スコアの算出方法を解明してくれているサイトを発見。こちらの記事を参考にさせて頂き実装しました。

スコアは「WPM × 正確率^3」の小数点以下切り捨てだったようです。というわけで、そのまま以下のようにコード化。

score = isStopped ? '-' : Math.floor(speed * accuracy ** 3);

「ミスだけ」「もう1回」機能

「ミスだけ」機能

recordMというミスした文章(のインデックス)を記録しているデータがあるので、それに格納されているwordJP1wordJP2のデータで元のwordJP1wordJP2を上書きし、ミスした文章だけを打ち直せるようにしています。

ここの処理がちょっとくせ者で、「ミスだけ」から通常のタイピングに戻るときにwordJP1wordJP2を元に戻してあげる必要があるので、「ミスだけ」を選択した際にあらかじめ元のwordJP1wordJP2のバックアップを取っておきます。

// ゲーム開始処理
function gameInit() {
  ...

  if (moPlay) { // 「ミスだけ」を選択した場合
    let missJP1 = [];
    let missJP2 = [];
    for (let i = 0; i < recordM.length; i++) {
      missJP1.push(wordJP1[recordM[i]]);
      missJP2.push(wordJP2[recordM[i]]);
    } 
    if (backup1.length == 0) {
      backup1 = [...wordJP1]; // wordJP1のバックアップ
      backup2 = [...wordJP2]; // wordJP2のバックアップ
    }
    wordJP1 = missJP1;
    wordJP2 = missJP2;
  } else {
    if (backup1.length > 0) {
      wordJP1 = [...backup1];
      wordJP2 = [...backup2];
      backup1 = [];
      backup2 = [];
    }
  }
  recordM = []; // recordMの初期化
  ...
}

この部分が最初なかなかうまくいかず苦労しました。

「もう1回」機能

こちらは「ミスだけ」と違って特別な処理は必要なく、単純に「スタート」を押した後のタイピング開始前の状態に戻します。

// リプレイ処理
function replay() {
  moPlay = false; // 「ミスだけ」フラグなし
  result.style.display = 'none'; // 結果画面を非表示に
  view2.style.display = 'block'; // タイピング画面を表示
  startMsg.style.display = 'block'; // メッセージを再表示

  sWait = true; // 開始前のスペース入力待ちフラグ
  space.classList.add('active'); // スペースキーをactiveに
}

大文字/小文字切り替え機能搭載版

ご要望を頂いたため、大文字/小文字切り替え機能搭載版を作成しました。Ctrlキーで問題文とキーボードの大文字/小文字表示を切り替えることができます。

タイピングゲームv1.1

以下の「コードを確認」をクリックして開き、ソースコードをコピーして使ってください。

コードを確認

--

★こちらの記事もおすすめです↓

【JavaScript】e-typing風の本格派タイピングゲームを作る(英語編) | IB-Note

e-typing風タイピングゲームの英語版を作成しました。シフト操作ありで、英数字に加えて各種記号にも対応した本格モデルです。

28件のコメント
シフトに対応してほしかったです
キーガイドoffでキーボードごと消せるようにする方法小文字で表示できるようにする方法が有ったら教えて欲しいです
>匿名さん
・キーガイドoffでキーボードごと消せるようにする方法
キーガイドoffで消す仕様にする場合、JavaScriptを少しいじる必要があります。
普段からキーガイドを使用しない場合、簡単なやり方としてはCSSで

#virtual-keyboard {
 position: relative;
 width: 610px;
 margin: 0 auto;
 /* キーボードを消す */
 display: none;
}

と設定する方法があります。

・小文字で表示できるようにする方法
#sentenceのCSSで、以下のようにtext-transformをコメントアウトしてください。

/* text-transform: uppercase; */
「ローカル環境での遊び方」はmacの場合、どのようにソースの保存などを進めたらよろしいでしょうか。
>匿名さん
Macを使用していないのでよくわかりませんが、基本的には同じ手順で進めて頂けたらと思います。Macでのメモ帳へのソースコード貼り付けと保存は以下の記事が参考になるかと思います。

参考記事:Windowsの「メモ帳」のMac版として、「テキストエディット」を使ってみる
コードを拝見させていただきました。素晴らしいですね。
このタイピングゲームを元に手を加えて、離婚で離れたところに住む子供にタイピングを練習させたいと考えています。離れて暮らしているので、web上でのアクセスになります。コードを利用させていただいてよろしいでしょうか?必要であればサイト主様の記載もページに仕込みたいと思います。
いかがでしょうか?
>高橋さん
コメントありがとうございます。
商用利用等でなければ、コードはご自由に使って頂いて大丈夫です。
当サイトの記載も特に必要ありません。
よろしくお願いいたします。
小文字、大文字ですが、スイッチで切り替えできるようになるといいなぁと思います。またローマ字で入力文字のガイドが出てきますが、それもスイッチと連動して大文字、小文字が切り替わるようになると最高ですね
>kumakumaさん
ご意見ありがとうございます。
小文字・大文字の切り替え機能に関して検討しておきます。
しゅーてー しゅーてー 2023/04/28 15:08
とてもコンパクトなアプリでありつつ、
タイピング練習アプリとして十分な機能を備えており、
素晴らしい出来だと感じました。

自分は公的な教育関係に従事している者ですが、
教育現場でタイピング習得が必須の今、
子供たちに安心して使用させるため、登録作業や広告がなかったり、
ワードをカスタマイズできたり、キーを小文字にできたり(Chrombookのため)、
それでいて無料のものとなると、なかなか好適なものがないのが現状です。

しかしFumaさんのこの公開アプリを見て、これだ!と思いました。
ついては、学校の授業でぜひこのアプリを
カスタマイズをしつつ使用させてもらいたいと考えているのですが、
大丈夫でしょうか?

何卒よろしくお願いします
>しゅーてーさん
コメントありがとうございます。
趣味で作成したタイピングゲームですが、こうして注目していただけてとても嬉しいです。
教育目的とのことですので、カスタマイズ等しつつ自由に使っていただければと思います。
よろしくお願いいたします。
素晴らしいものを作成していただいてありがとうございます!
タイピングの練習として使わせてもらってます!

ただ、数字が先頭のお題を入れると、先に進まなくなるという点が私の力では治せなくて・・・
>匿名さん
コメントありがとうございます。
タイピングゲームがお役に立っているようで嬉しいです。

問題の件に関して、調べて解決でき次第コードを更新します。
修正版完成まで今しばらくお待ちください。
現在勉強中で、ソースを改造して、色々変更しようとすると難しいですね。Youtubeなんかでタイピングゲームの作り方の解説なんかをアップしてくれたら、とっても勉強できるなと思いました。是非とも解説を!
>匿名さん
ご意見ありがとうございます。
確約はできかねますが、今後動画解説のほうも検討しようと思います。
たまちゃん たまちゃん 2023/05/12 12:07
利用させていただいてます。使っていて気づきました。問題に長文を設定した場合にローマ字ガイド、かなガイド、キーガイドをoffで取り組むのですが、おそらくローマ字ガイドの影響でしょう、問題文が左にスクロールしすぎて見えなくなります。今から打たないといけない言葉も見えなくなります。どのように調整すれば良いでしょうか?

また、難読漢字クイズをタイピングゲームで作ろうとした場合、かなガイド、ローマ字ガイド、キーガイドを使えなくしたいとも思います。どのようにすれば良いでしょうか?
たまちゃん たまちゃん 2023/05/12 15:19
連投すいません。
問題文が見えなくなる問題ですが、一つの漢字に対してローマ字の文字数が多い場合が連続していくと問題文が隠れるようです。調整方法があるといいのですが…
>たまちゃんさん
ご報告ありがとうございます。

問題文の件ですが、今回作成したタイピングゲームは長文を想定したものではないため、追加の調整対応は難しい状況です。根本的に解決するためには、ゲーム自体を長文仕様のものに作り変える必要があります。

かなガイド、ローマ字ガイド、キーガイドを使えなくする場合は、ソースコード中のflagR, flagK, flagGをデフォルトでfalseとしておき、flagsに関するコードを修正(flags[0]~flags[2]に関する行を削除し、flags[3]とflags[4]を新しくflags[0]とflags[1]に設定)する必要があります。
たまちゃん たまちゃん 2023/05/14 6:33
なるほど、現状では文字スクロールの調整はできないのですね。

それでしたら、問題文をどこかに全文表示させておくことができたらいいな、と思います。

たとえばゲームウィンドウの上部や下部などに表示させることは可能ですか?
>たまちゃんさん
本家e-typingの長文版を参考に、そのような仕様に作り変えることは可能だと思います。
ただし、長文タイピング用に内部処理の修正が必要になります。
プログラム初心者 プログラム初心者 2023/07/03 20:59
大変素晴らしいコードを公開して頂き、ありがとうございます!日々、練習に活用させて頂いてます。

下記、要求の通り修正頂く事は可能ですか?

【現状】
1つの文の入力が終わった瞬間に、次の文が始まる。

【要求】
この、文と文が変わる間にインターバル時間を入れたい。
(本家は300ms程?とりあえず適当に)

【懸念】
インターバルを入れると、全体の時間計算が大変になる?

【背景】
ドライアイの為、インターバル無しだと、まばたきのタイミングが難しく、そこに気が行って、今のタイピングの実力と関係ない所でロスが生じてしまう。

(実務(議事録等)でのタイピングでは、文章の脳へのインプットは耳からの事が多いので、練習で目の負担は最小限にしたい)

本家の様に、文と文の間のインターバルで、まばたきの時間が取れれば、変なロスに気を取られずタイピング自体に集中できると思い、修正を試みましたが、私のスキルだと難しく、困っています。。

---下記の通り考え、試してみました---

// タイピング文章セット
辺りで、
「setInterval」「clearInterval」
を使うのかも?と思っていますが、
どう書いて良いか分からずにいます。

記述済みの、
「setInterval」「clearInterval」
を探し、
// カウントダウン処理
で、試しに、
1000msから数値を変え、動確。
-----------------------------------
ここみたいに、数字を変えるだけで済む状態だと、ありがたいです。

長文となり、失礼しました。

自分で色々使いこなせるようになったら、帰省した際に、タイピングを始めたばかりの小中学生の甥っ子達に作ってあげたいです。

以上、よろしくお願いいたします。
>プログラム初心者さん
コメントありがとうございます。
タイピングゲームがお役に立っているようで嬉しいです。

ご要望の件ですが、その他機能との兼ね合いもあり、すぐには対応できない状況です。
代わりに、以下でインターバル導入の方針のみ示します。

======================

1.まず、ご指摘の通りwordSet()部分を以下のように書き換える必要があります。

function wordSet() {
 if (count == limit) {
  finish();
 } else {
  let func = function() {
   // 本来のelse以下の処理
  };
  if (count == 0) {
   func();
  } else {
   setTimeout(func, 300); // 300ms後に表示
  }
 }
}

2.インターバルが経過時間に含まれてしまうため、経過時間処理を修正します。(主に「time = ~」部分)

3.インターバル中かを判断するフラグ(isInterval)を用意し、インターバル中はミス判定にならないようにします。

======================

以上、ざっくりとではありますが、ご参考になれば幸いです。
よろしくお願いいたします。
プログラム初心者 プログラム初心者 2023/07/07 22:13
>Fumaさん

お忙しい中、ご丁寧に回答いただき、
ありがとうございます。

やはり、難しいようですね。。

コードのカスタム方法として、エクセルを使って、
変えたい部分をセルに抜き出しておいて、それを
反映する方法をとってます。
(こういうのの一般的な修正方法は知りませんが)

前述の【要求】を満たすため、調べた結果
「setInterval」が使えそうだと思いコード全文を
「time」(大小文字区別:有・無、両方)でも検索し
何とか、やりたい動作に近いコードが、
//カウントダウン処理
辺り等で使われている所まで何とか分かりました。

しかし仮に【要求】動作のみ出来るようになっても、
時間を使った計算が、修正前後で全て問題なく
調整できる自信が無く自分だと難しそうだった為、
ダメ元で前回の投稿でご相談させて頂きました。

ご教授頂いた方法で、まずは、何か簡単な
動作で試してみて理解したうえで、更に、
「time = ~」部分の理解も再度試みてみようと
思います。

公開していただいた状態の物で既に、かなり
理想に近いありがたいコードだったのですが、
使ってるうちに、自分のスキルも顧みず、
もっと改善したいと欲が出てしまいました。。

自分でコード修正ができるかは分かりませんが、
タイピング練習自体は、これで続けていきたいと
思います。ありがとうございました。
初めまして,タイピングの練習に非常に役立っております.
問題文をcsvファイルなどから読み込んで表示させることは可能でしょうか??
>マロアさん
初めまして。コメントありがとうございます。
問題文をcsvファイル等から読み込んで表示させることは可能ですが、読み込み処理追加のためにプログラムを工夫して書き換える必要があります。JavaScriptのローカルファイル読み込み方法に関しては以下の記事が参考になるかと思います。

https://www.sejuku.net/blog/32532
「神っぽいな」とタイピングする際に
①kamippoina
②kamixtupoina
③kamiltupoina
の3パターンあると思うのですが、現状①でしか入力できないようです。
>匿名さん
冒頭でも記載の通り、本家e-typingで対応していない入力方式には対応していません。
ご了承ください。
素晴らしいコードをありがとうございます。
中学校英語の授業で使わせていただきたいと考えています。
英語入力にするには相当な変更が必要でしょうか。
最近プロゲートやyoutube等でプログラミングを勉強していますが、素人です。
図々しいお願いになってしまっているのはわかっているのですが、数日かけてもなかなか改善できないため、
可能でしたら教えていただけたら幸いです。

①タイトルの変更
②表示文章=日本語訳
③ひらがな文章=英語

上記の変更を試みたところ、動かなくなってしまいました。
他にも英語入力のテンプレートを他から引っ張ってきて、はったりしたのですが、なかなかうまくいきません。
従来の日本語タイピングよりもコード的には簡単になると思いますが、なかなかできません。

現在思い付きで

④かな>ローマ字変換の部分を打ち換えていっています。
'あ': ['a'] → 'a': ['a']

なにとぞご助言のほどよろしくお願いいたします。
>匿名さん
コメントありがとうございます。
ご相談の件ですが、本タイピングゲームの英語版はいかがでしょうか?
お求めの条件を満たした仕様になっていると思います。

https://itblogger-note.blogspot.com/2022/10/typing-game-english.html

①については、上記記事で紹介しているソースコード内の
<div id="game-title">ことわざ</div>
の部分でタイトルを変更可能です。