🙆

ブラウザだけで完結!JavaScriptで「落ち物式」タイピングゲームを作ってみた(LocalStorage対応)

に公開

はじめに
JavaScriptの学習アウトプットとして、ブラウザで遊べるシンプルなタイピングゲームを制作しました。 外部ライブラリを使わず、標準的なDOM操作とrequestAnimationFrame、そしてlocalStorageを用いたデータ保存を実装しています。

実装した主な機能
アニメーション: requestAnimationFrameによる滑らかな落下処理

当たり判定: 特定の「バー」の上にある時だけ入力を受け付ける判定ロジック

スコアシステム: 連続正解によるコンボ(倍率)機能

データ永続化: localStorageを活用したハイスコア記録

こだわった実装ポイント

  1. 滑らかな落下アニメーション
    setTimeoutではなくrequestAnimationFrameを採用することで、ブラウザのリフレッシュレートに合わせたスムーズな描画を実現しました。

function move() {
  const top = text.offsetTop;
  text.style.top = top + speed + 'px';

  if (top > gameContainer.clientHeight) {
    text.remove();
    createFallingText(); // 画面外に出たら新しい文字を生成
  } else {
    requestAnimationFrame(move);
  }
}
  1. 位置に応じたタイピング文字の制限
    画面を左右に分割したエリアごとに、降ってくる文字を制限するロジックを組んでいます(例:左端なら「qaz」、右端なら「p」など)。これにより、実際のキーボード配置に近い感覚で練習できる仕様にしました。

  2. 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