1. はじめに
ドットインストールのJavaScript講座でスロットマシンを作りました。今回の実装では class 構文・再帰的な setTimeout・絵柄の一致判定ロジックなど、これまでの講座の中でも特に盛りだくさんの内容でした。この記事では、実装を通じて理解したことを整理します。
2. 完成コード
今回作成したスロットマシンの全コードです。HTML・CSS・JavaScriptの3ファイルで構成されています。
2.1 HTML
シンプルな構成です。<main> の中身はJavaScriptで動的に生成するため、HTMLにはほとんど何も書きません。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Slot Machine</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<main></main>
<div id="spin">SPIN</div>
<script src="js/main.js"></script>
</body>
</html>
2.2 CSS
body {
background: #bdc3c7;
font-size: 16px;
font-weight: bold;
font-family: Arial, sans-serif;
}
main {
width: 300px;
background: #ecf0f1;
padding: 20px;
border: 4px solid #fff;
border-radius: 12px;
margin: 16px auto;
display: flex;
justify-content: space-between;
}
.panel img {
width: 90px;
height: 110px;
margin-bottom: 4px;
}
.stop {
cursor: pointer;
width: 90px;
height: 32px;
background: #ef454a;
box-shadow: 0 4px 0 #d1483e;
border-radius: 16px;
line-height: 32px;
text-align: center;
font-size: 14px;
color: #fff;
user-select: none;
}
#spin {
cursor: pointer;
width: 280px;
height: 36px;
background: #3498db;
box-shadow: 0 4px 0 #2880b9;
border-radius: 18px;
line-height: 36px;
text-align: center;
color: #fff;
user-select: none;
margin: 0 auto;
}
.unmatched {
opacity: 0.5;
}
.inactive {
opacity: 0.5;
}
2.3 JavaScript
今回の実装の中心です。Panel クラスにパネル1枚分のDOM生成・アニメーション・判定ロジックをまとめています。
'use strict';
{
class Panel {
// パネル1枚分のDOM要素を生成してmainに追加する
constructor() {
const section = document.createElement('section');
section.classList.add('panel');
this.img = document.createElement('img');
this.img.src = this.getRandomImage();
this.timeoutId = undefined;
this.stop = document.createElement('div');
this.stop.textContent = 'STOP';
this.stop.classList.add('stop', 'inactive');
// STOPボタンが押されたときの処理
this.stop.addEventListener('click', () => {
if (this.stop.classList.contains('inactive')) {
return;
}
this.stop.classList.add('inactive');
// タイマーを止めて、残りパネル数をカウントダウンする
clearTimeout(this.timeoutId);
panelsLeft--;
// 全パネルが止まったら判定を実行してSPINを再度有効にする
if (panelsLeft === 0) {
checkResult();
spin.classList.remove('inactive');
panelsLeft = 3;
}
});
section.appendChild(this.img);
section.appendChild(this.stop);
const main = document.querySelector('main');
main.appendChild(section);
}
// ランダムに画像パスを1つ返す
getRandomImage() {
const images = [
'img/seven.png',
'img/bell.png',
'img/cherry.png',
];
return images[Math.floor(Math.random() * images.length)];
}
// 50msごとに画像を切り替えて回転アニメーションを再帰的に繰り返す
spin() {
this.img.src = this.getRandomImage();
this.timeoutId = setTimeout(() => {
this.spin();
}, 50);
}
// 自分の絵柄がp1・p2どちらとも一致しない場合にtrueを返す
isUnmatched(p1, p2) {
return this.img.src !== p1.img.src && this.img.src !== p2.img.src;
}
// 揃っていないパネルの画像を半透明にする
unmatch() {
this.img.classList.add('unmatched');
}
// 次のゲームに備えてパネルを初期状態に戻す
activate() {
this.img.classList.remove('unmatched');
this.stop.classList.remove('inactive');
}
}
// 3枚の絵柄を比較して揃っていないパネルを薄く表示する
function checkResult() {
if (panels[0].isUnmatched(panels[1], panels[2])) {
panels[0].unmatch();
}
if (panels[1].isUnmatched(panels[0], panels[2])) {
panels[1].unmatch();
}
if (panels[2].isUnmatched(panels[0], panels[1])) {
panels[2].unmatch();
}
}
// パネルを3枚生成する(生成と同時にDOMへの追加も行われる)
const panels = [
new Panel(),
new Panel(),
new Panel(),
];
// 残りSTOP数を管理するカウンター
let panelsLeft = 3;
const spin = document.getElementById('spin');
// SPINボタンが押されたら全パネルを活性化して回転を開始する
spin.addEventListener('click', () => {
if (spin.classList.contains('inactive')) {
return;
}
spin.classList.add('inactive');
panels.forEach(panel => {
panel.activate();
panel.spin();
});
});
}
3. 実装を通じて理解したこと
この実装で特に学びになったポイントを5つに絞って整理します。
3.1 class でパネルを管理する設計
1枚のパネルに必要な要素(画像・STOPボタン・タイマーID)をすべて class の中にまとめる設計が、今回の核心でした。
constructor() の中で <section>・<img>・<div> をそれぞれ生成し、DOMに追加しています。
パネルを3つ作るときは、インスタンスを3つ生成するだけで済みます。
const panels = [
new Panel(),
new Panel(),
new Panel(),
];
「要素の生成・イベント登録・DOMへの追加をすべて constructor() 内でやる」という書き方は、クラスを使う利点がよく出ているみたいです。
3.2 setTimeout の再帰呼び出しでアニメーションを実現する
画像をパラパラ切り替えるアニメーションは、setTimeout を再帰的に呼び出すことで実現しています。
spin() {
this.img.src = this.getRandomImage();
// 50ms後に自分自身をもう一度呼び出す
this.timeoutId = setTimeout(() => {
this.spin();
}, 50);
}
ポイントは、setTimeout の戻り値(タイマーID)を this.timeoutId に保存していることです。STOPボタンが押されたとき、clearTimeout(this.timeoutId) でタイマーを止めています。
タイマーIDを保存しておかないと、後から clearTimeout() で止めることができません。「止める手段を確保してから動かす」という発想が大切みたいです。
3.3 「揃っていないもの」を探す判定ロジック
3枚の絵柄が揃っているかの判定は、isUnmatched() メソッドで行っています。
「揃っているものを探す」ではなく「揃っていないものを探す」という発想で書かれているのが特徴的と理解しました。
isUnmatched(p1, p2) {
// 自分の絵柄がp1とも違い、p2とも違うなら「揃っていない」
return this.img.src !== p1.img.src && this.img.src !== p2.img.src;
}
checkResult() 関数では、3枚それぞれに対して isUnmatched() を呼び、揃っていないパネルだけ unmatch() で薄く表示します。
function checkResult() {
if (panels[0].isUnmatched(panels[1], panels[2])) {
panels[0].unmatch();
}
if (panels[1].isUnmatched(panels[0], panels[2])) {
panels[1].unmatch();
}
if (panels[2].isUnmatched(panels[0], panels[1])) {
panels[2].unmatch();
}
}
3枚すべてバラバラの場合は3枚とも薄くなり、2枚が同じ場合は残り1枚だけが薄くなります。3枚すべて同じ(大当たり)のときは isUnmatched() が全部 false を返すため、何も薄くならないみたいです。
3.4 panelsLeft でSTOP完了数を管理する
3つのSTOPボタンがすべて押されたタイミングで判定を実行するため、panelsLeft という変数でカウントダウンしています。
let panelsLeft = 3;
STOPが押されるたびに panelsLeft-- し、0 になったら checkResult() を呼び出します。判定後は panelsLeft = 3 でリセットします。
this.stop.addEventListener('click', () => {
if (this.stop.classList.contains('inactive')) {
return;
}
this.stop.classList.add('inactive');
// タイマーを止めて、残りパネル数をカウントダウンする
clearTimeout(this.timeoutId);
panelsLeft--;
// 全パネルが止まったら判定を実行してSPINを再度有効にする
if (panelsLeft === 0) {
checkResult();
spin.classList.remove('inactive');
panelsLeft = 3;
}
});
3.5 inactive クラスでボタンの有効・無効を制御する
SPINボタンとSTOPボタンの有効・無効は、inactive クラスの付け外しで管理しています。
CSSで .inactive に opacity: 0.5 を設定しておくことで、見た目を薄くしています。
// ボタンを非活性にする
this.stop.classList.add('inactive');
// ボタンを活性化する
this.stop.classList.remove('inactive');
まとめ
今回のスロットマシン実装で理解できたことをまとめます。
-
classを使うと、1つのパネルに必要な要素・状態・ロジックをひとまとめにできるみたいです -
setTimeoutの再帰呼び出しでアニメーションを実現し、戻り値のtimeoutIdを保存しておくことでclearTimeout()で止められると理解しました - 「揃っていないものを探す」という
isUnmatched()の発想は、判定ロジックをシンプルに書く工夫のようです -
panelsLeftのようなカウンター変数で「全員がアクションを完了したか」を管理するパターンは、複数要素を扱う場面で使えそうと感じました
