この記事はドットインストール「JavaScriptでタイピングゲームを作ろう」を学習するための記事です。
今まで学んできたことを複合的に使うため、初心者にとってはなかなかやりがいのあるプログラムでした。
まだ慣れていないためこのコードはどういう意味なのかなど、引っかかったところがあったので、そのあたりを自分なりにアウトプットすることで、理解を深めたいです。
まずはHTMLです。
<p id="target">word</p>
<p class="info">
Letter count: <span id="score">0</span>
Miss count: <span id="miss">0</span>
</p>
target部が打つ文字です。成功数やミスの数をカウントするので、そのエリアも用意します。
次にJavaScript。
タイピングゲームなので、
・打つべき文字列を用意し
・タイプした文字を取得し
・それを表示する
というおおまかな流れがあるのでそれを作ります。
const word = 'apple'; // 打つべき文字列
const target = document.getElementById('target'); // 表示エリアを取得
target.textContent = word; // 打った文字列をセット
window.addEventListener('keydown', e => { // キーを押したら以下の処理を実行
console.log(e.key); // ひとまずコンソール表示
});
ざっくりこんな感じになります。
次にタイプする文字は、文字列の何番目かを管理する必要があるので、それをlocとして宣言します。
ただしこれは後に値を再代入するので、constではなくletを使います。
同様に、正解数やミス数を管理し、それを表示させるための要素を取得します。
let loc = 0;
let score = 0;
let miss = 0;
const target = document.getElementById('target');
const scoreLabel = document.getElementById('score');
const missLabel = document.getElementById('miss');
正誤判定
次は正誤判定です。
打ったキー(e)がwordのloc番目の文字と同じか判定して、正解ならscoreカウントをプラス1して次の文字へ、不正解ならmissカウントをプラス1し、それらをブラウザ上に表示させます。
window.addEventListener('keydown', e => {
if (e.key === word[loc]) { // 打ったキー(e)がwordのloc番目の文字と同じなら
loc++; // 次の文字へ
score++; // 正解数プラス1
scoreLabel.textContent = score; // 正解数を表示
} else { // 違っていたら
miss++; // ミス数プラス1
missLabel.textContent = miss; // ミス数を表示
}
});
次に打つ文字をわかりやすくする
次に行うのは、「正解した文字をなにか別のものに変えて、次に打つ文字をわかりやすくする」です。
'apple'とずっと表示されていても、どれを打つべきなのかわからないと困りますよね。3番目まで正解していたのなら**'_ _ _le'**となっていれば、次に打つのは'l'なんだとすぐにわかります。
従って、上記の内容を今何文字目を打つべきかはlocで管理してるので、0からloc番目までを'_'で埋めることで処理します。
そのアンダースコアを格納するための変数をplaceholderと定義して、空の文字列に初期化しておきます。そして、正解した数(locの数)の分だけアンダースコアを連結させます。
それができたらブラウザにplaceholder + wordのインデックスがloc番目以降と表示させるためにtargetを更新します。
これにはsubstringを使います。
substring(引数) // 引数以降の部分文字列を取得
function updateTarget() { // 正解した文字を _ に変換させる
let placeholder = ''; // '_'を格納するための空の変数
for (let i = 0; i < loc; i++) {
placeholder += '_'; // 呼び出された数だけ'_'を連結する
}
target.textContent = placeholder + word.substring(loc); // loc番目までは'_'、loc番目以降はそのまま表示
}
window.addEventListener('keydown', e => {
if (e.key === word[loc]) {
loc++;
updateTarget(); // 上記を呼び出す
score++;
scoreLabel.textContent = score;
} else {
miss++;
missLabel.textContent = miss;
}
});
これで打つべき文字がわかりやすくなりました。
問題を複数用意し、正解したら次の問題へ移行する
問題をwordsという配列に用意し、それをwordにランダムで入れていきます。
const words = [
'apple',
'sky',
'blue',
'middle',
'set',
];
let word = words[Math.floor(Math.random() * words.length)];
次に問題の文字を全部打ち終わったら次の問題へ移行する、です。
これを実行するタイミングは、正解する(locを更新したタイミング)で、もしlocが問題の文字列数と一致したら、次のwordsに移行する、で良いでしょう。
従ってif (e.key === word[loc])
内にそれを判定するif文を追加します。
window.addEventListener('keydown', e => {
if (e.key === word[loc]) {
loc++;
if (loc === word.length) { // locが問題の文字列数と一致したら
word = words[Math.floor(Math.random() * words.length)]; // 別の問題を選択する
loc = 0; // locを0に初期化
}
updateTarget();
score++;
scoreLabel.textContent = score;
} else {
miss++;
missLabel.textContent = miss;
}
});
これについて**Math.random()を使った際に、現在と同じものが選択されないのか?**と気になったのですが、どうやらそれはないようです。なので、改めてランダムを呼び出す = 次の問題へ以降ということになります。
次の問題へ移行したら、打つべき文字は新しい問題のインデックス0番目になるので、忘れずにloc = 0;
しましょう。
ゲームをスタートさせる
ページを表示したときにまず'click to start'と表示し、クリックしたらゲームが始まるようにします。
html側で
<p id="target">click to start</p>
とし、JavaScript側でウィンドウをクリックしたら、word(問題)を表示させる処理を書きます。これもwindowに対してイベントを追加します。
window.addEventListener('click', () => {
target.textContent = word; // 問題を表示
}
});
これで間違いなく表示されます。
タイマーを追加する
まずタイマーの初期設定です。
<p class="info">
Letter count: <span id="score">0</span>
Miss count: <span id="miss">0</span>
Time left: <span id="timer">0.00</span> <!--timer表示エリア-->
</p>
let loc = 0;
let score = 0;
let miss = 0;
const timeLimit = 3 * 1000; // ミリ秒なので1000倍し、3秒に設定
let startTime = 0; // ゲームスタート時刻を保持するための変数
const target = document.getElementById('target');
const scoreLabel = document.getElementById('score');
const missLabel = document.getElementById('miss');
const timerLabel = document.getElementById('timer'); // タイマー要素取得
次は最初にウィンドウがクリックされた時にゲームが始まるので、その時刻をstartTimeに代入し、残り時間を表示させます。その関数updateTimer
とし、別途用意しましょう。
window.addEventListener('click', () => {
target.textContent = word;
startTime = Date.now(); // 現在時刻を代入
updateTimer(); // 残り時間表示関数
}
});
ゲームの残り時間を計算するupdateTimer
関数を設定します。
残り時間はゲーム開始時刻 + 制限時間 - 現在時刻で計算できます。
例えばゲーム開始時刻:0:00:10 , 制限時間:3秒 , 現在時刻 0:00:12なら10 + 3 - 12 = 残り1秒とわかりますね。toFixed(n)は小数点以下n位まで表示させるというメソッドです。
そして上記を一定時間ごとに繰り返すことで、残り時間をカウントダウンさせます。
function updateTimer() {
const timeLeft = startTime + timeLimit - Date.now(); // 残り時間を計算
timerLabel.textContent = (timeLeft / 1000).toFixed(2); // タイマーラベルに秒で表示
const timeouId = setTimeout(() => { // updateTimerを呼んだ10ミリ秒後に
updateTimer(); // updateTimerを呼び出す = updateTimerを繰り返す
}, 10);
if (timeLeft < 0) { // 残り時間が0以下になったら
clearTimeout(timeoutId); // timeoutIdを解除する
alert('Game Over'); // アラートを表示
}
}
setTimeoutとは一定時間後に処理を実行するです。この場合、10ミリ秒後に残り時間を計算し表示する、を繰り返し、残り時間が0になったらclearTimeout
で処理を終えてアラート表示させます。
タイマーの不具合を解消する
上記の場合、アラートが表示された際に残り時間が0.00から少しずれてしまうことがあります。
その対策として、ゲームが終わったらtextContentで0.00と表示させます。
if (timeLeft < 0) {
clearTimeout(timeoutId);
timerLabel.textContent = '0.00'; // 0.00を表示
alert('Game Over');
}
ただしこれでも正確に表示されません。その原因はブラウザによってはアラートの処理が終わるまで画面描画処理がロックされることがあるからです。要はアラート表示が早いためですね。従ってアラート表示を遅らせます。
if (timeLeft < 0) {
clearTimeout(timeoutId);
timerLabel.textContent = '0.00';
setTimeout(() => { // 100ミリ秒後にアラートを表示させる
alert('Game Over');
}, 100);
}
こういうのは知らないと対処できない問題なので、今後のためにも対策含めてちゃんと覚えておきたいですね。
他の不具合を解消する
実行してみるとわかるのですが、
・ゲーム中クリックを連打するとその度タイマーが戻ってしまうことや、
・最後にそのクリックした数のアラートが出てしてしまうこと、
・さらにクリックしてスタートする前でもタイピング判定が行われる
などの問題が残っています。
これらは、クリックしたらした分だけゲームが起動しているからなんですね。従って、ゲームを起動したらゲーム中というフラグを持たせて、多重起動しない、つまりupdateTimerが走らないようにします。
まずは最初にフラグをもたせます。当然始まっていないのでfalseですね。
let loc = 0;
let score = 0;
let miss = 0;
const timeLimit = 3 * 1000;
let startTime = 0;
let isPlaying = false;
そしてゲームが始まったらisPlaying
をtrueにします。ただすでにisPlaying
がtrueなら処理させないというのも必要ですね。そのあたりを条件分岐します。
window.addEventListener('click', () => {
if (isPlaying === true) { // isPlaying が true なら
return; // 以下の処理をせずにreturn
}
isPlaying = true; // isPlaying を true へ
target.textContent = word;
startTime = Date.now();
updateTimer();
}
});
同様に、isPlaying
がfalseになるのはゲームが終わった時なので、
if (timeLeft < 0) {
isPlaying = false; // ゲームが終了したので isPlaying を false へ
clearTimeout(timeoutId);
timerLabel.textContent = '0.00';
setTimeout(() => { // 100ミリ秒後にアラートを表示させる
alert('Game Over');
}, 100);
}
となります。
あわせてスタート前でも判定してしまうのをどうにかするには、keydown時にisPlaying
を確認してtrueじゃないならreturnすると付け加えてあげましょう。
window.addEventListener('keydown', e => {
if (isPlaying !== true) { // タイプ時に isPlaying が true じゃなかったら return する
return;
}
if (e.key === word[loc]) {
loc++;
if (loc === word.length) {
word = words[Math.floor(Math.random() * words.length)];
loc = 0;
}
updateTarget();
score++;
scoreLabel.textContent = score;
} else {
miss++;
missLabel.textContent = miss;
}
});
こうすることで対応します。
正解率を表示する
ゲームが終わると現在はアラートでただ'Game Over'と表示されますが、これを正解率表示にします。
計算は正解数 / 正解数 + ミス数 * 100です。
ただ正解数 + ミス数が0だと計算できないので条件演算子を使っていきます。
条件式 ? Trueの処理 : Falseの処理
function updateTimer() {
const timeLeft = startTime + timeLimit - Date.now();
timerLabel.textContent = (timeLeft / 1000).toFixed(2);
const timeoutId = setTimeout(() => {
updateTimer();
}, 10);
if (timeLeft < 0) {
isPlaying = false;
clearTimeout(timeoutId);
timerLabel.textContent = '0.00';
setTimeout(() => {
showResult(); // 正解率表示関数を呼び出す
}, 100);
}
}
function showResult() {
const accuracy = score + miss === 0 ? 0 : score / (score + miss) * 100; // 正解率計算
alert(`${score} letters, ${miss} misses, ${accuracy.toFixed(2)}% accuracy!`); // 正解率表示
}
これでうまくいきました。
リプレイができるようにする
ゲームオーバー後に'click to replay'と表示させます。
function updateTimer() {
const timeLeft = startTime + timeLimit - Date.now();
timerLabel.textContent = (timeLeft / 1000).toFixed(2);
const timeoutId = setTimeout(() => {
updateTimer();
}, 10);
if (timeLeft < 0) {
isPlaying = false;
clearTimeout(timeoutId);
timerLabel.textContent = '0.00';
setTimeout(() => {
showResult();
}, 100);
target.textContent = 'click to replay'; // リプレイを促すメッセージを表示
}
}
もともとこれはwindowをクリックすることで、ゲームが始まるのでその段階で色々と初期化するようにすれば、リプレイにも対応できそうです。従ってwindow.addEventListener('click', () =>
に色々書いてしまいましょう。
window.addEventListener('click', () => {
if (isPlaying === true) {
return;
}
isPlaying = true;
// 以下、各項目の初期化
loc = 0;
score = 0;
miss = 0;
scoreLabel.textContent = score;
missLabel.textContent = miss;
word = words[Math.floor(Math.random() * words.length)];
target.textContent = word;
startTime = Date.now();
updateTimer();
}
});
あわせて、もともとあった初期化の項目は、宣言のみにしてあげます。
let word; // 宣言のみ
let loc; // 宣言のみ
let score; // 宣言のみ
let miss; // 宣言のみ
const timeLimit = 3 * 1000;
let startTime; // 宣言のみ
let isPlaying = false;
完成
以上ですべてのコードを書き終えました。
完成したコードは以下になります。
'use strict';
{
const words = [
'apple',
'sky',
'blue',
'middle',
'set',
];
let word;
let loc;
let score;
let miss;
const timeLimit = 3 * 1000;
let startTime;
let isPlaying = false;
const target = document.getElementById('target');
const scoreLabel = document.getElementById('score');
const missLabel = document.getElementById('miss');
const timerLabel = document.getElementById('timer');
function updateTarget() {
let placeholder = '';
for (let i = 0; i < loc; i++) {
placeholder += '_';
}
target.textContent = placeholder + word.substring(loc);
}
function updateTimer() {
const timeLeft = startTime + timeLimit - Date.now();
timerLabel.textContent = (timeLeft / 1000).toFixed(2);
const timeoutId = setTimeout(() => {
updateTimer();
}, 10);
if (timeLeft < 0) {
isPlaying = false;
clearTimeout(timeoutId);
timerLabel.textContent = '0.00';
setTimeout(() => {
showResult();
}, 100);
target.textContent = 'click to replay';
}
}
function showResult() {
const accuracy = score + miss === 0 ? 0 : score / (score + miss) * 100;
alert(`${score} letters, ${miss} misses, ${accuracy.toFixed(2)}% accuracy!`);
}
window.addEventListener('click', () => {
if (isPlaying === true) {
return;
}
isPlaying = true;
loc = 0;
score = 0;
miss = 0;
scoreLabel.textContent = score;
missLabel.textContent = miss;
word = words[Math.floor(Math.random() * words.length)];
target.textContent = word;
startTime = Date.now();
updateTimer();
});
window.addEventListener('keydown', e => {
if (isPlaying !== true) {
return;
}
if (e.key === word[loc]) {
loc++;
if (loc === word.length) {
word = words[Math.floor(Math.random() * words.length)];
loc = 0;
}
updateTarget();
score++;
scoreLabel.textContent = score;
} else {
miss++;
missLabel.textContent = miss;
}
});
}
かなり長い記事になってしまいました。もしかしたら分割するべきだったかもしれません。
一応編集する時はそのブロックごとにしたのですが、正直わかりにくかったと思います。
プログラムのように上に行ったり下に行ったりとする場合、どのように書いていくべきかまだつかめておりません。
行番号でも追加したほうがよかったかな。今後の課題にしたいです。