🙆
ブラウザだけで完結!JavaScriptで「落ち物式」タイピングゲームを作ってみた(LocalStorage対応)
はじめに
JavaScriptの学習アウトプットとして、ブラウザで遊べるシンプルなタイピングゲームを制作しました。 外部ライブラリを使わず、標準的なDOM操作とrequestAnimationFrame、そしてlocalStorageを用いたデータ保存を実装しています。
実装した主な機能
アニメーション: requestAnimationFrameによる滑らかな落下処理
当たり判定: 特定の「バー」の上にある時だけ入力を受け付ける判定ロジック
スコアシステム: 連続正解によるコンボ(倍率)機能
データ永続化: localStorageを活用したハイスコア記録
こだわった実装ポイント
- 滑らかな落下アニメーション
setTimeoutではなくrequestAnimationFrameを採用することで、ブラウザのリフレッシュレートに合わせたスムーズな描画を実現しました。
function move() {
const top = text.offsetTop;
text.style.top = top + speed + 'px';
if (top > gameContainer.clientHeight) {
text.remove();
createFallingText(); // 画面外に出たら新しい文字を生成
} else {
requestAnimationFrame(move);
}
}
-
位置に応じたタイピング文字の制限
画面を左右に分割したエリアごとに、降ってくる文字を制限するロジックを組んでいます(例:左端なら「qaz」、右端なら「p」など)。これにより、実際のキーボード配置に近い感覚で練習できる仕様にしました。 -
localStorageによるハイスコア保存
サーバーサイドを用意せず、ブラウザのストレージ機能を使って、ページを閉じても記録が残るようにしています。
function updateHighScore(score) {
if (score > highScore) {
highScore = score;
localStorage.setItem("highScore", highScore); // ここで保存
}
}
今後の改善案
レスポンシブ対応の強化: 現状は vh/vw で調整していますが、スマホ操作時の挙動をより最適化したい。
難易度の動的変化: スコアに応じて落下のスピードが上がるロジックの追加。
制作を通した気づき
単純なゲームですが、DOMの生成・削除のサイクルや、アニメーションのループ処理など、JavaScriptの基本が詰まった良い練習材料になりました。
htmlコード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>タイピングゲーム</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="game-container">
<div id="bar">Type Here</div>
</div>
<div id="score">スコア: 0</div>
<div id="high-score">ハイスコア: 0</div>
<button id="start-button">スタート</button>
<button id="restart-button">もう一度プレイ</button>
<div id="game-description">
<p>画面上部から落ちてくる文字をType Hereのバー上でタイプして消してください。</p>
<p>30秒間でできるだけ多くの文字を入力し、高いスコアを目指しましょう。</p>
<p>連続でタイプに成功するとスコアがさらに増えます。</p>
<p>スタートボタンを押すと開始します。</p>
</div>
<div id="timer">残り時間: 30秒</div>
<script src="script.js"></script>
</body>
</html>
CSSコード
/* 全体の配置:中央に縦に並べる */
body {
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
background-color: #f0f0f0;
font-family: sans-serif;
}
/* ゲーム画面の外枠 */
#game-container {
position: relative; /* 内部の文字やバーの基準 */
width: 90vw;
max-width: 400px;
height: 400px;
background-color: #fff;
border: 2px solid #ccc;
overflow: hidden;
margin-bottom: 20px;
}
/* 判定用のバー */
#bar {
position: absolute;
bottom: 30px;
left: 0;
width: 100%;
height: 50px;
background-color: #3498db;
text-align: center;
line-height: 50px;
color: #fff;
font-weight: bold;
}
/* スコア表示パネル */
.info-panel {
text-align: center;
background: #fff;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
width: 90vw;
max-width: 400px;
}
.status-text {
font-size: 1.2rem;
margin: 5px 0;
}
/* 落ちてくる文字のスタイル */
.falling-text {
position: absolute;
transform: translateX(-50%);
font-size: 2rem;
font-weight: bold;
}
/* ゲームオーバー表示 */
#game-over {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2rem;
color: #e74c3c;
z-index: 10;
}
JavaScriptコード
const gameContainer = document.getElementById('game-container');
const bar = document.getElementById('bar');
const scoreDisplay = document.getElementById('score');
const highScoreDisplay = document.getElementById('high-score');
const startButton = document.getElementById('start-button');
const restartButton = document.getElementById('restart-button');
// ゲームオーバー表示用の要素を動的に生成
const gameOverText = document.createElement('div');
gameOverText.id = 'game-over';
gameOverText.innerText = 'ゲームオーバー';
gameContainer.appendChild(gameOverText);
let score = 0;
let multiplier = 1;
let consecutiveCorrectKeyPresses = 0;
let gameTimer;
let timeLeft = 30;
let highScore = parseInt(localStorage.getItem("highScore")) || 0;
// 初期表示
highScoreDisplay.innerHTML = "ハイスコア: " + highScore;
// 1. カウントダウンと開始処理
startButton.addEventListener('click', () => {
startButton.style.display = 'none';
init();
});
function init() {
score = 0;
timeLeft = 30;
multiplier = 1;
updateScoreDisplay();
createFallingText();
startGameTimer();
document.addEventListener('keypress', handleKeyPress);
}
// 2. 文字の生成と位置決定
function createFallingText() {
const text = document.createElement('div');
text.className = 'falling-text';
// キーボード位置に基づいた文字グループ
const characters = {
'1': 'qaz', '2': 'wsx', '3': 'edc', '4': 'rfv', '5': 'tgb',
'6': 'yhn', '7': 'ujm', '8': 'ik', '9': 'ol', '10': 'p'
};
const randPos = Math.floor(Math.random() * 10) + 1;
const targetChars = characters[randPos.toString()];
text.innerText = targetChars[Math.floor(Math.random() * targetChars.length)];
text.style.left = `${(randPos - 1) * 9 + 5}%`;
text.style.top = '0%';
gameContainer.appendChild(text);
const speed = Math.random() * 1.5 + 2;
move(text, speed);
}
// 3. 落下アニメーション
function move(element, speed) {
const top = element.offsetTop;
element.style.top = top + speed + 'px';
if (top > gameContainer.clientHeight) {
element.remove();
if (timeLeft > 0) createFallingText();
} else {
requestAnimationFrame(() => move(element, speed));
}
}
// 4. タイピング判定
function handleKeyPress(event) {
const fallingText = document.querySelector('.falling-text');
if (fallingText && event.key === fallingText.innerText && isOnBar(fallingText)) {
fallingText.remove();
increaseScore();
createFallingText();
}
}
// 5. 当たり判定(バーの上に重なっているか)
function isOnBar(element) {
const barRect = bar.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
return (
elementRect.top <= barRect.bottom &&
elementRect.bottom >= barRect.top
);
}
// 6. スコア・タイマー管理
function increaseScore() {
consecutiveCorrectKeyPresses++;
multiplier = consecutiveCorrectKeyPresses >= 3 ? Math.floor(consecutiveCorrectKeyPresses / 3) + 1 : 1;
score += 10 * multiplier;
updateScoreDisplay();
}
function updateScoreDisplay() {
scoreDisplay.textContent = `スコア: ${score} (${multiplier}倍)`;
}
function startGameTimer() {
gameTimer = setInterval(() => {
timeLeft--;
document.getElementById('timer').textContent = `残り時間: ${timeLeft}秒`;
if (timeLeft <= 0) gameOver();
}, 1000);
}
// 7. 終了処理とハイスコア保存
function gameOver() {
clearInterval(gameTimer);
gameOverText.style.display = 'block';
restartButton.style.display = 'block';
document.removeEventListener('keypress', handleKeyPress);
if (score > highScore) {
highScore = score;
localStorage.setItem("highScore", highScore);
highScoreDisplay.innerHTML = "ハイスコア: " + highScore;
}
}
function restartGame() {
location.reload();
}
Discussion