1. はじめに
ドットインストールの「JavaScriptで数字タッチゲームを作ろう」を受講しました。0から順番に数字を押していくゲームで、クリアまでのタイムを計測します。今回の講座でいちばん学びが多かったのはクラス設計とタイマーの仕組みだったので、そこを中心に整理してみました。
2. 完成コード
HTML・CSS・JavaScriptの3ファイル構成です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Numbers Game</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<div id="container">
<div id="timer">0.0</div>
<ul id="board">
</ul>
<div id="btn">START</div>
</div>
<script src="js/main.js"></script>
</body>
</html>
body {
background: #ccc;
color: #fff;
font-family: 'Courier New', sans-serif;
font-size: 16px;
font-weight: bold;
}
#container {
margin: 16px auto;
}
#board {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 8px;
padding: 10px;
background: #fff;
border-radius: 4px;
}
#board li {
background: #00aaff;
width: 40px;
height: 40px;
margin: 5px;
cursor: pointer;
border-radius: 4px;
line-height: 40px;
text-align: center;
box-shadow: 0 4px 0 #0088cc;
}
#board li.pressed {
background: #ccc;
box-shadow: none;
margin-top: 9px;
margin-bottom: 1px;
}
#timer {
margin-bottom: 8px;
font-size: 20px;
text-align: right;
}
#btn {
cursor: pointer;
user-select: none;
background: #f44336;
padding: 8px;
border-radius: 4px;
text-align: center;
box-shadow: 0 4px 0 #d1483e;
}
#btn:active {
margin-top: 12px;
box-shadow: none;
}
'use strict';
{
class Panel {
constructor(game) {
// Gameインスタンスを受け取り、Gameのメソッドをどこからでも呼べるようにする
this.game = game;
this.el = document.createElement('li');
// 初期状態はpressedクラスを付けて非表示にする
this.el.classList.add('pressed');
this.el.addEventListener('click', () => {
this.check();
});
}
getEl() {
return this.el;
}
activate(num) {
// pressedクラスを外して数字を表示する
this.el.classList.remove('pressed');
this.el.textContent = num;
}
check() {
// textContentは文字列なのでparseIntで数値に変換して比較する
if (this.game.getCurrentNum() === parseInt(this.el.textContent, 10)) {
this.el.classList.add('pressed');
this.game.addCurrentNum();
// 全パネルを押し終えた(currentNum === level²)らタイマーを止める
if (this.game.getCurrentNum() === this.game.getLevel() ** 2) {
clearTimeout(this.game.getTimeoutId());
}
}
}
}
class Board {
constructor(game) {
this.game = game;
this.panels = [];
// level² 個のPanelを生成して配列に格納する
for (let i = 0; i < this.game.getLevel() ** 2; i++) {
this.panels.push(new Panel(this.game));
}
this.setup();
}
setup() {
const board = document.getElementById('board');
// 全PanelのDOM要素をboardに追加する
this.panels.forEach(panel => {
board.appendChild(panel.getEl());
});
}
activate() {
const nums = [];
for (let i = 0; i < this.game.getLevel() ** 2; i++) {
nums.push(i);
}
this.panels.forEach(panel => {
// spliceでランダムな位置から1つ取り出す(元の配列から削除されるので重複しない)
const num = nums.splice(Math.floor(Math.random() * nums.length), 1)[0];
panel.activate(num);
});
}
}
class Game {
constructor(level) {
this.level = level;
// thisを渡すことでBoardとPanelからGameのメソッドを参照できる
this.board = new Board(this);
this.currentNum = undefined;
this.startTime = undefined;
// タイマーIDを保持してclearTimeoutで止められるようにする
this.timeoutId = undefined;
const btn = document.getElementById('btn');
btn.addEventListener('click', () => {
this.start();
});
this.setup();
}
setup() {
const container = document.getElementById('container');
const PANEL_WIDTH = 50;
const BOARD_PADDING = 10;
// レベルに応じてコンテナ幅を動的に計算する(例: 5 * 50 + 10 * 2 = 270px)
container.style.width = PANEL_WIDTH * this.level + BOARD_PADDING * 2 + 'px';
}
start() {
// 連打されたとき前のタイマーが残っていたら止める
if (typeof this.timeoutId !== 'undefined') {
clearTimeout(this.timeoutId);
}
this.currentNum = 0;
this.board.activate();
// 開始時刻を記録してタイマーの基準点にする
this.startTime = Date.now();
this.runTimer();
}
runTimer() {
const timer = document.getElementById('timer');
// 開始時刻との差分をミリ秒→秒に変換して小数点2桁で表示する
timer.textContent = ((Date.now() - this.startTime) / 1000).toFixed(2);
// setTimeoutを再帰的に呼び出すことでタイマーを動かし続ける
this.timeoutId = setTimeout(() => {
this.runTimer();
}, 10);
}
addCurrentNum() {
this.currentNum++;
}
getCurrentNum() {
return this.currentNum;
}
getTimeoutId() {
return this.timeoutId;
}
getLevel() {
return this.level;
}
}
// レベル5(5×5 = 25枚)でゲームを起動する
new Game(5);
}
3. クラス設計:3つのクラスの役割分担
今回のコードは Panel・Board・Game の3クラスで構成されているみたいです。それぞれの責務を整理してみました。
3.1 Gameクラス――ゲーム全体を管理する
Game クラスがすべての起点になっているみたいです。レベル数・現在の正解番号・タイマーのIDなどを一元管理しています。
Board を生成するときに this(自分自身)を渡しているのがポイントで、これによって Board や Panel から Game のメソッドを呼び出せる設計になっているようです。
Gameクラスのコンストラクタ部分です。
constructor(level) {
this.level = level;
// thisを渡すことでBoardからGameのメソッドを参照できる
this.board = new Board(this);
this.currentNum = undefined;
this.startTime = undefined;
this.timeoutId = undefined;
}
Board に this を渡すパターンは、親が子に「自分への参照」を渡す設計みたいです。子クラスは受け取った参照を通じて親のメソッドを使えるようになります。
3.2 Boardクラス――パネルの集合を管理する
Board クラスはパネルの生成・DOMへの追加・数字のランダム配置を担当しているみたいです。
パネルを level ** 2 個生成して配列に格納します。** はべき乗演算子で、5×5のグリッドなら 5 ** 2 で25個になるようです。
パネルを生成してDOMに追加するコードです。
constructor(game) {
this.game = game;
this.panels = [];
// level ** 2 = 5 ** 2 = 25個のパネルを生成
for (let i = 0; i < this.game.getLevel() ** 2; i++) {
this.panels.push(new Panel(this.game));
}
this.setup();
}
setup() {
const board = document.getElementById('board');
this.panels.forEach(panel => {
board.appendChild(panel.getEl());
});
}
数字をランダムに配置するコードです。
activate() {
const nums = [];
for (let i = 0; i < this.game.getLevel() ** 2; i++) {
nums.push(i);
}
this.panels.forEach(panel => {
// spliceでランダムな位置から1つ取り出す(取り出した要素は配列から削除される)
const num = nums.splice(Math.floor(Math.random() * nums.length), 1)[0];
panel.activate(num);
});
}
splice で取り出した要素は元の配列から削除されるみたいです。これによって同じ数字が2回選ばれることなく、重複なしでシャッフルできる仕組みのようです。
3.3 Panelクラス――1枚のパネルを管理する
Panel クラスは1枚の <li> 要素を管理しているみたいです。クリック時に正解かどうかを check() で判定します。
クリック判定のコードです。
check() {
if (this.game.getCurrentNum() === parseInt(this.el.textContent, 10)) {
this.el.classList.add('pressed');
this.game.addCurrentNum();
// 全パネルを押し終えたらタイマーを止める
if (this.game.getCurrentNum() === this.game.getLevel() ** 2) {
clearTimeout(this.game.getTimeoutId());
}
}
}
parseInt(this.el.textContent, 10) でテキストを整数に変換しているのがポイントで、textContent は文字列として取れるため数値比較のために変換が必要なようです。
parseInt の第2引数 10 は基数(10進数)の指定です。省略しても動くことが多いですが、明示的に書くのが安全みたいです。
4. タイマーの仕組み
タイマーは setTimeout を再帰的に呼び出すことで実現しているみたいです。
setInterval ではなく setTimeout + 再帰にする理由は、処理が重なって遅延したときにズレが起きにくいからのようです。
タイマーを動かし続けるコードです。
runTimer() {
const timer = document.getElementById('timer');
// Date.now()で現在時刻を取得し、開始時刻との差分をミリ秒→秒に変換
timer.textContent = ((Date.now() - this.startTime) / 1000).toFixed(2);
// 10ms後に自分自身を呼び出す
this.timeoutId = setTimeout(() => {
this.runTimer();
}, 10);
}
Date.now() はミリ秒単位の現在時刻を返すみたいです。1000 で割ることで秒に変換し、toFixed(2) で小数点2桁に揃えているようです。
タイマーを止めるときは clearTimeout に timeoutId を渡します。timeoutId を Game クラスで一元管理しているので、Panel からも this.game.getTimeoutId() 経由で止められる設計になっているようです。
5. コンテナ幅の動的計算
Game クラスの setup() では、レベルに応じてコンテナ幅を動的に計算しているみたいです。
コンテナの幅をJavaScriptで計算するコードです。
setup() {
const container = document.getElementById('container');
const PANEL_WIDTH = 50;
const BOARD_PADDING = 10;
// PANEL_WIDTH * level + BOARD_PADDING * 2 = 50 * 5 + 10 * 2 = 270px
container.style.width = PANEL_WIDTH * this.level + BOARD_PADDING * 2 + 'px';
}
定数を PANEL_WIDTH・BOARD_PADDING のように大文字で定義しているのは、変更しない値だと明示するための慣習みたいです。
レベルを変えると自動でコンテナ幅が変わる設計になっているようです。new Game(5) の引数を変えるだけでグリッドサイズが切り替わります。
まとめ
-
Panel・Board・Gameの3クラスに責務を分けることで、コードの見通しがよくなるみたいです - クラスに
thisを渡すことで、子クラスから親クラスのメソッドを参照できる設計になるようです -
spliceを使った重複なしシャッフルは、配列から取り出すたびに要素が減る仕組みを利用していると理解しました -
setTimeoutの再帰呼び出しでタイマーを実装する方法を初めて知りました -
Date.now()で経過時間を計算し、toFixed(2)で表示を整える流れが理解できたみたいです
