JavaScriptでポケモン風バトルゲームをゼロから作ろう!

JavaScriptでポケモン風バトルゲームをゼロから作ろう!

HTML・CSS・JavaScriptだけで、ブラウザで動く本格的なターン制バトルゲームを完成させるチュートリアルです。 タイプ相性・クリティカル・チャージ攻撃・派手なエフェクト・8bit風BGMまで実装して、ポケモン級の手応えがある作品に仕上げよう。

⏱ 読了:約30分 🎯 対象:プログラミング初心者〜

📋 もくじ

  1. 完成するとどうなる?
  2. 準備するもの
  3. ファイル構成を作ろう
  4. HTMLで画面の骨組みを作ろう
  5. CSSでモンスターを描こう(divだけでスプライト表現)
  6. JavaScriptの基本構造を書こう
  7. ステートマシンでゲームの状態を管理しよう
  8. async/awaitでターン進行を書こう(最重要!)
  9. メッセージのタイプライター演出(Promiseの実践)
  10. ダメージ計算とタイプ相性表を作ろう
  11. HPバーを動かそう(色がじわっと変わる演出)
  12. 攻撃エフェクトを4タイプ作ろう(パーティクル)
  13. 画面シェイクと点滅で被弾を演出しよう
  14. Web Audio APIでBGMと効果音をコードだけで作ろう
  15. 完成コード全文
  16. 動かしてみよう
  17. 次のステップ

完成するとどうなる?

この記事を最後まで読むと、ブラウザ上で動く本格的なターン制バトルゲームが完成します。ただのバトルゲームじゃなくて、こんな機能がついています:

  • 自分のモンスター「フレイム(ほのおタイプ)」 vs 敵「ウォータス(みずタイプ)」のバトル
  • タイプ相性でダメージが2倍/0.5倍に変動(ほのお→くさは2倍、ほのお→みずは0.5倍)
  • 1/16の確率でクリティカルヒット(1.5倍ダメージ)
  • 4つの技がそれぞれ PP(使用回数)を持っていて、使い切ると選べなくなる
  • タイプごとに違う攻撃エフェクト(炎は立ち上る/水は波紋/草は葉が舞う/ノーマルは斬撃)
  • 被弾時の画面シェイク・スプライト点滅・低音SEで痛みが伝わる
  • HPバーが緑→黄→赤とじわっと色変化する演出
  • メッセージはタイプライター方式で1文字ずつ表示(クリックでスキップ可)
  • 音声ファイルなし!Web Audio APIでコードだけで効果音とループBGMを生成
  • 倒れたら「もういちどたたかう」ボタンでリプレイ可能

使うのは HTML・CSS・JavaScript の3つだけ。特別なライブラリやフレームワークは使いません。さらに画像ファイルすら使いません。モンスターはCSSのdivborder-radiusだけで描きます。

準備するもの

以下の2つだけ用意してください。

  • テキストエディタWeb版のVS Codeだとインストール不要!)
  • Webブラウザ(Chrome、Edge、Safariなど何でもOK)

ファイル構成を作ろう

パソコンのどこかにフォルダを1つ作ります。名前は pokemon-battle にしましょう。その中に3つのファイルを作ります。

pokemon-battle/
    index.html ─ 画面の構造(モンスター・UI・パネル)
    style.css  ─ 見た目のデザイン(CSSでモンスターも描く)
    script.js  ─ ゲームの動き(バトル進行・音・エフェクト)

Webページは基本的にこの3つの役割分担でできています。HTMLが骨組み、CSSが見た目、JavaScriptが動き。アクションゲームの記事と同じ構成です。

HTMLで画面の骨組みを作ろう

index.html を開いて、以下のコードを書いてください。今回はアクションゲームと違って <canvas> は使いません。通常のHTMLタグでバトル画面を組み立てます

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>ポケモン風バトルゲーム</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div id="game">
    <!-- ===== バトルフィールド ===== -->
    <div id="battle-field">
      <!-- 地面プラットフォーム(楕円の影) -->
      <div id="enemy-ground"></div>
      <div id="player-ground"></div>

      <!-- 敵モンスタースプライト(divの組み合わせで描く) -->
      <div id="enemy-sprite">
        <div class="wat-shell"></div>
        <div class="wat-body"></div>
        <div class="wat-head"></div>
        <!-- ... 略 ... -->
      </div>

      <!-- 自分のモンスタースプライト -->
      <div id="player-sprite">
        <div class="fla-flame fla-flame-1"></div>
        <div class="fla-body"></div>
        <!-- ... 略 ... -->
      </div>

      <!-- ステータスパネル(HPバーつき) -->
      <div id="enemy-status" class="status-box">...</div>
      <div id="player-status" class="status-box">...</div>

      <!-- 攻撃エフェクトを表示するレイヤー -->
      <div id="effect-overlay"></div>
    </div>

    <!-- ===== バトルUI下部 ===== -->
    <div id="battle-ui">
      <div id="message-window"><p id="message-text"></p></div>
      <div id="command-panel" class="hidden">...</div>
      <div id="moves-panel" class="hidden">...</div>
      <div id="replay-panel" class="hidden">...</div>
    </div>
  </div>
  <script src="script.js"></script>
</body>
</html>

HTMLのポイント解説

アクションゲームと違って、画面の要素1つ1つが独立したHTMLタグになっています。なぜCanvasを使わずdivを選ぶのか? そのほうがCSSのアニメーションやフィルター効果が使いやすいからです。HPバーをじわっと色変化させたり、スプライトを点滅させたり、CSSが得意なことはCSSに任せた方がコードがシンプルになります。

#battle-field がバトル画面、#battle-ui が下のメッセージ・コマンド領域。3つのパネル(メッセージ/コマンド/技選択/リプレイ)はすべて class="hidden" で初期状態は隠しておいて、JavaScriptで切り替えます。

💡 ステータスパネルの構造

HPバーは「外枠 + 中身」の2つのdivを重ねた構造です。

<div class="hp-bar-outer">
  <div class="hp-bar-inner" style="width:100%"></div>
</div>

外枠は固定サイズ、中身の width をJavaScriptから変えるだけでHPの増減を表現できます。これはよく使う鉄板パターンなので覚えておきましょう。

CSSでモンスターを描こう(divだけでスプライト表現)

ここがこのチュートリアルの一番面白いところです。画像ファイル一切なしで、CSSだけでモンスターを描きます。

基本的な考え方

  • 1つのdivborder-radius)と位置を指定すれば、円や楕円が描ける
  • それを複数重ねることで、頭・体・お腹・しっぽ……というパーツに分解
  • radial-gradient立体感(ハイライトと影)を出す
  • z-index で前後関係(しっぽは奥、頭は手前など)を制御

敵モンスター「ウォータス」を描く

/* 甲羅(一番奥) */
.wat-shell {
  position: absolute;
  left: 8px; top: 22px;
  width: 92px; height: 82px;
  background: radial-gradient(ellipse at 40% 35%, #006064, #00363a);
  border-radius: 50% 50% 45% 45% / 55% 55% 45% 45%;
  box-shadow: inset -4px -4px 8px rgba(0,0,0,0.4);
}

/* 体 */
.wat-body {
  background: radial-gradient(ellipse at 40% 30%, #26c6da, #0097a7);
  /* ... */
}

/* お腹(明るい色) */
.wat-belly {
  background: radial-gradient(ellipse at 45% 40%, #fff9e6, #ffe082);
  z-index: 2;
}

/* 頭(一番手前) */
.wat-head {
  background: radial-gradient(ellipse at 38% 30%, #4dd0e1, #0097a7);
  z-index: 3;
}

このように 下から順に重ねていくのがポイント。z-index を使って「甲羅 < 体 < お腹 < 頭 < 目」の順に手前にしていきます。

🔑 border-radius のマジック

border-radius には実は2つのスラッシュ記法があります。

border-radius: 50% 50% 45% 45% / 55% 55% 45% 45%;
/*              ↑ X方向(横) / Y方向(縦) */

スラッシュの左側がX方向の半径、右側がY方向の半径。これを使うと、「上半分は丸く、下半分はやや尖る」みたいなゆがんだ卵型が作れます。これがあると、ただの楕円じゃなくて「動物っぽい」「キャラクターっぽい」シルエットになります。

自分のモンスター「フレイム」のしっぽの炎

フレイムはしっぽの先で炎が揺らめいているのが特徴。これは3つのdivを重ねて、それぞれにアニメーションをつけて表現します。

.fla-flame {
  border-radius: 50% 50% 30% 30%; /* 雫型 */
  animation: flicker 0.6s ease-in-out infinite alternate;
}
.fla-flame-1 { width: 32px; height: 42px; background: ...オレンジ赤; }
.fla-flame-2 { width: 24px; height: 34px; background: ...黄オレンジ; animation-delay: 0.1s; }
.fla-flame-3 { width: 16px; height: 24px; background: ...黄色;     animation-delay: 0.2s; }

@keyframes flicker {
  0%   { transform: scaleX(1)    scaleY(1)    rotate(-2deg); }
  100% { transform: scaleX(0.9)  scaleY(1.05) rotate(2deg);  }
}

ポイントは animation-delay炎ごとにタイミングをずらすこと。3つの炎が揃って動くと「機械的」に見えますが、ずらすことで自然な揺らぎが出ます。

💡 ドット絵が描けなくても大丈夫

「絵が描けないから無理」と思った方、安心してください。色 + 形 + 位置の組み合わせだけでキャラクターを表現する手法です。むしろドット絵を描くより、コードを書ける人にとってはこっちの方が楽かもしれません。完成コードのCSSをコピーして、widthbackgroundの数値をいじるだけで全然違うモンスターが作れます。

背景は1つのグラデーションで「空 → 草原」を表現

#battle-field {
  background: linear-gradient(
    to bottom,
    #7ec8e3 0%,    /* 空(薄水色) */
    #b8dff0 28%,
    #d4ecc4 52%,   /* 地平線 */
    #8bc34a 54%,   /* 草原開始 */
    #6aaa35 70%,
    #558b2f 100%   /* 草原(濃い緑) */
  );
}

複数のカラーストップを使うと、1行で「空が下にいくほど明るくなって、地平線で急に草原に変わる」という景色が作れます。52%54% の間でガクッと色が変わるところが地平線です。

JavaScriptの基本構造を書こう

ここからがメイン! script.js に書いていくコードを、セクションごとに解説していきます。

モンスターのデータをテンプレート化する

const PLAYER_BASE = {
  name: "フレイム",
  level: 25,
  maxHp: 120,
  attack: 55,
  defense: 40,
  speed: 60,
  type: "fire",
  moves: [
    { name: "かえんほうしゃ", type: "fire",   power: 90, pp: 15, maxPp: 15 },
    { name: "ひっかく",       type: "normal", power: 40, pp: 35, maxPp: 35 },
    { name: "リーフカッター", type: "grass",  power: 75, pp: 20, maxPp: 20 },
    { name: "アクアジェット", type: "water",  power: 60, pp: 25, maxPp: 25 },
  ],
};

const ENEMY_BASE = { /* ウォータスのデータ ... */ };

モンスターの能力値・覚えている技を「テンプレート」として定義しておきます。バトルが始まるたびにこのテンプレートをコピーして使うので、ここを書き換えれば敵の強さや技の威力を簡単に調整できます。

🔑 なぜテンプレートをコピーするのか?

バトル中、HPやPPはどんどん減っていきます。もしテンプレート自体を直接いじってしまうと、2回目のバトルで「最初からHPが減ったまま」になってしまうんです。リプレイ時に毎回満タンに戻すために、テンプレートをコピーしてからゲームに使うというルールにしています。

function deepCopy(obj) {
  return JSON.parse(JSON.stringify(obj));
}

// 使用例
player = deepCopy(PLAYER_BASE);
player.hp = player.maxHp;

JSON.parse(JSON.stringify(...))オブジェクトを丸ごとコピーする裏ワザ。一度文字列に変換してから戻すことで、元のオブジェクトと一切繋がりのない新しいオブジェクトが作れます。これを「ディープコピー」と呼びます。

タイプ相性表をオブジェクトで作る

ポケモンの肝といえばタイプ相性ですね。これも素直にデータで持ちます。

const TYPE_CHART = {
  fire:  { grass: 2, water: 0.5, fire: 0.5 },
  water: { fire: 2, grass: 0.5, water: 0.5 },
  grass: { water: 2, fire: 0.5, grass: 0.5 },
  normal: {},
};

これは2次元テーブルをオブジェクトで表現したものです。TYPE_CHART['fire']['grass']2、つまり「ほのお → くさ」は2倍ダメージ。

💡 if文の連鎖との比較

これを if で書くとどうなるか、見比べてみましょう。

// ❌ if文だらけバージョン(読みにくい・追加しにくい)
if (attackType === 'fire' && defenderType === 'grass') return 2;
if (attackType === 'fire' && defenderType === 'water') return 0.5;
if (attackType === 'water' && defenderType === 'fire') return 2;
// ... タイプを増やすたびにif文が爆発的に増える
// ✅ オブジェクト参照バージョン(拡張しやすい)
function getTypeEffectiveness(attackType, defenderType) {
  const row = TYPE_CHART[attackType] || {};
  return row[defenderType] !== undefined ? row[defenderType] : 1;
}

新しいタイプを追加したくなったら TYPE_CHART の中身を増やすだけ。コードのロジックは触らなくてOK。「データ」と「ロジック」を分離するのはプログラミング全般で大事な考え方です。

ステートマシンでゲームの状態を管理しよう

バトルゲームには「いま何をしている画面か」という状態があります。

let gameState = 'start';
//   'start'    : 起動直後
//   'busy'     : メッセージ表示中など、操作を受け付けない
//   'command'  : 「たたかう」などコマンド選択中
//   'moves'    : 技選択中
//   'gameover' : バトル終了後

これがステートマシン(状態機械)の考え方です。アクションゲームの記事でもボス戦などで出てきましたが、バトルゲームではこれが進行管理の心臓部になります。

なぜステートマシンが必要?

ボタンを押したときの動作は今どの状態かによって変わるからです。

状態

「たたかう」を押したら?

技ボタンを押したら?

command

技選択画面に進む ✅

何も起きない

moves

何も起きない

その技を発動 ✅

busy

何も起きない

何も起きない

gameover

何も起きない

何も起きない

ボタンのイベントリスナーで、最初に必ず gameState をチェックします。

btnFight.addEventListener('click', () => {
  if (gameState !== 'command') return; // ← この一行が大事
  showMovesUI();
});

これがないと、「メッセージ表示中に連打したら技が連発した」みたいな致命的バグが起きます。シンプルですが、絶対に省略してはいけない安全装置です。

async/awaitでターン進行を書こう(最重要!)

ここが今回の記事で一番伝えたいところです。バトルゲームのターン進行は、

  1. 「フレイムの かえんほうしゃ!」とメッセージを出す(1.5秒待つ
  2. 攻撃エフェクトを表示する(1.3秒待つ
  3. ダメージ音と画面シェイクを実行する(0.5秒待つ
  4. 「こうかは ばつぐんだ!」と表示(1秒待つ
  5. 敵の反撃へ……

という「待つ」の連続でできています。これを普通に書くと地獄です。

❌ 旧式の書き方(コールバック地獄)

// 古い書き方:処理が右にずれていく
showMessage("かえんほうしゃ!", () => {
  showAttackEffect("fire", () => {
    playDamageSound(() => {
      updateHpBar(enemy);
      showFlash(() => {
        showMessage("こうかはばつぐんだ!", () => {
          // 敵のターン...
        });
      });
    });
  });
});

これはコールバック地獄と呼ばれる、初心者を泣かせる代表的なコードパターンです。読みにくいだけでなく、エラー処理も難しい。

✅ async/awaitでスッキリ書く

同じ処理を、最新の書き方で書くとこうなります。

async function executeAttack(attacker, defender, move, attackerSide) {
  await showMessage(`${attacker.name}の ${move.name}!`);

  const { damage, effectiveness, isCritical } = calcDamage(attacker, defender, move);

  if (isCritical) {
    playCriticalSound();
    await showMessage('きゅうしょに あたった!');
  }

  playAttackSound(move.type);
  const defenderSide = attackerSide === 'player' ? 'enemy' : 'player';
  await showAttackEffect(move.type, defenderSide);

  playDamageSound();
  defender.hp = Math.max(0, defender.hp - damage);
  updateHpBar(defenderSide, defender);
  await showDamageEffects(defenderSide);

  if (effectiveness >= 2) {
    playSuperEffectiveSound();
    await showMessage('こうかは ばつぐんだ!');
  } else if (effectiveness <= 0.5) {
    playNotEffectiveSound();
    await showMessage('こうかは いまひとつ…');
  }
}

上から下に読めるコードになりました! await は「この処理が終わるまで一旦待ってね」という命令です。コールバックを使わなくても、待ちながら処理を進められます。

🔑 async/awaitのルール

  • await を使う関数には、必ず async をつける(async function
  • awaitPromiseを返す関数にしか使えない
  • await を書いた行でそこの処理は止まる(後ろの行は実行されない)
  • await で待っている間、他のJavaScriptは止まらない(イベントは普通に処理される)

最後の点が重要。「処理は止まるけど、ブラウザは止まらない」 だから、メッセージ表示中もユーザーがクリックすればちゃんと反応してくれる、というわけです。

1ターンの流れも、上から下に読める

async function executeTurn(playerMoveIndex) {
  gameState = 'busy';

  const playerMove = player.moves[playerMoveIndex];
  const enemyMove = getEnemyMove();

  if (player.speed >= enemy.speed) {
    // プレイヤー先攻
    await executeAttack(player, enemy, playerMove, 'player');
    if (enemy.hp <= 0) { await endBattle('player'); return; }
    await executeAttack(enemy, player, enemyMove, 'enemy');
    if (player.hp <= 0) { await endBattle('enemy'); return; }
  } else {
    // 敵先攻(処理を逆順にするだけ)
    await executeAttack(enemy, player, enemyMove, 'enemy');
    if (player.hp <= 0) { await endBattle('enemy'); return; }
    await executeAttack(player, enemy, playerMove, 'player');
    if (enemy.hp <= 0) { await endBattle('player'); return; }
  }

  showCommandUI();
}

速度が高い方が先に攻撃するロジックが、ifを一段挟むだけで自然に書けています。各 executeAttack の後に「相手のHPが0になったらバトル終了」のチェックを挟むのもポイント。死んだあとに反撃させないためです。

メッセージのタイプライター演出(Promiseの実践)

await showMessage(...)showMessage の中身、気になりませんか?「メッセージを1文字ずつ表示しつつ、終わったら次に進める」を実現するには Promise を自分で作る必要があります。

const TYPEWRITER_INTERVAL_MS = 30; // 1文字ごとの間隔
const MESSAGE_HOLD_MS = 700;       // 全文表示後の余韻

function showMessage(text) {
  return new Promise(resolve => {
    messageText.textContent = '';
    let charIndex = 0;
    let timerId = null;
    let isDone = false;

    function finishMessage() {
      if (isDone) return;       // 二重実行を防ぐ
      isDone = true;
      clearTimeout(timerId);
      messageText.textContent = text;
      messageWindow.removeEventListener('click', handleSkipClick);
      resolve();                // ← await側に「終わったよ」と伝える
    }

    // クリックで即座に全文表示してスキップ
    function handleSkipClick() { finishMessage(); }
    messageWindow.addEventListener('click', handleSkipClick);

    // 1文字ずつ追加
    function typeNextChar() {
      if (charIndex >= text.length) {
        timerId = setTimeout(finishMessage, MESSAGE_HOLD_MS);
        return;
      }
      messageText.textContent += text[charIndex];
      charIndex++;
      timerId = setTimeout(typeNextChar, TYPEWRITER_INTERVAL_MS);
    }
    typeNextChar();
  });
}

Promiseの心臓は resolve()

Promiseは「いつかは終わる処理」を表す箱のようなもの。箱の中で resolve() を呼ぶと、await していた側が動き出します。

return new Promise(resolve => {
  // ここに非同期処理を書く
  // 終わったら resolve() を呼ぶ
});

これだけ覚えておけば、自分でPromiseを作れるようになります。タイマーやアニメーション、画像読み込み……非同期処理を await で待ちたいときの標準パターンです。

クリックスキップ機能

ポケモン本編でもおなじみ、Aボタン連打でメッセージを送る挙動。これは「全文を即座に表示 + resolve を即座に呼ぶ」だけで実現できます。isDone フラグで二重実行を防いでいるのが地味なポイント。

ダメージ計算とタイプ相性表を作ろう

const CRITICAL_HIT_RATE = 1 / 16;       // 1/16の確率で急所
const CRITICAL_DAMAGE_BONUS = 1.5;      // 急所のとき1.5倍
const DAMAGE_VARIANCE_MIN = 0.85;       // 乱数下限
const DAMAGE_VARIANCE_RANGE = 0.15;     // 乱数の幅 (0.85〜1.00)

function calcDamage(attacker, defender, move) {
  const randomFactor   = DAMAGE_VARIANCE_MIN + Math.random() * DAMAGE_VARIANCE_RANGE;
  const effectiveness  = getTypeEffectiveness(move.type, defender.type);
  const isCritical     = Math.random() < CRITICAL_HIT_RATE;
  const criticalBonus  = isCritical ? CRITICAL_DAMAGE_BONUS : 1;

  const rawDamage =
    move.power *
    (attacker.attack / defender.defense) *
    effectiveness *
    criticalBonus *
    randomFactor;

  return {
    damage: Math.max(1, Math.floor(rawDamage)),
    effectiveness,
    isCritical,
  };
}

ダメージの公式

威力 × (攻撃 / 防御) × タイプ相性 × クリティカル × 乱数

ポケモン本家の式をかなりシンプル化したものですが、これだけでも十分「バトルゲームしてる」感じが出ます。

要素

効果

威力(power)

技ごとの基本ダメージ

かえんほうしゃ=90

攻撃 / 防御

攻撃側が強い・防御側が弱いほど大きく

55/40 = 1.375倍

タイプ相性

0.5 / 1 / 2 倍

火→草=2

クリティカル

1 or 1.5 倍

1/16の確率

乱数

0.85〜1.00

同じ攻撃でも毎回少し違う

最後の Math.max(1, ...)最低1ダメージは保証しているのもポイント。「0ダメージ」は気持ち悪いので、1だけは入るようにしています。

💡 なぜ乱数を入れるのか?

「同じ条件なら同じダメージ」だと、プレイヤーは最適解を計算で導けてしまうんです。乱数で0.85〜1.00倍のブレを入れると、「ギリギリ倒せると思ったのに耐えられた!」みたいなハラハラ感が生まれます。これがゲームを面白くする秘訣です。

敵CPUは「技をランダム選択」

function getEnemyMove() {
  const availableMoves = enemy.moves.filter(move => move.pp > 0);
  if (availableMoves.length === 0) {
    return { name: 'わるあがき', type: 'normal', power: 50, pp: 1, maxPp: 1 };
  }
  const randomIndex = Math.floor(Math.random() * availableMoves.length);
  return availableMoves[randomIndex];
}

「PPが残っている技だけを候補にして、その中からランダム選択」というシンプルなAI。全技のPPが切れたら「わるあがき」を撃ちます(ポケモン本家にもある仕様)。

もっと頭の良いAIにしたいなら、「相性が良い技を優先する」「HPが低いときは回復技を選ぶ」など、ここを書き換えれば改造できます。

HPバーを動かそう(色がじわっと変わる演出)

HPバーの動きには2つの工夫が入っています。

CSS transitionで滑らかに減少

.hp-bar-inner {
  transition: width 0.5s ease, background 0.4s ease;
}

transition を指定しておくと、JavaScriptで width を急に変えても、CSSが0.5秒かけて滑らかに動かしてくれるんです。手抜きに見えて実は超強力なテクニック。

HPの割合で色を切り替える

function getHpGradient(percent) {
  if (percent > 50) {
    return 'linear-gradient(to bottom, #66bb6a, #388e3c)'; // 緑(元気)
  }
  if (percent > 20) {
    return 'linear-gradient(to bottom, #ffd54f, #f9a825)'; // 黄(注意)
  }
  return 'linear-gradient(to bottom, #ef5350, #c62828)';   // 赤(危険)
}

function updateHpBar(side, monster) {
  const percent = Math.max(0, (monster.hp / monster.maxHp) * 100);
  const bar = side === 'player' ? playerHpBar : enemyHpBar;
  bar.style.background = getHpGradient(percent);  // 色を変える
  bar.style.width = percent + '%';                // 幅を変える
}

幅と一緒に背景のグラデーションも切り替えるので、HPが減るにつれて緑→黄色→赤と色が変わっていきます。これだけで「危ない!」という緊張感が伝わります。

数値もアニメーション

プレイヤー側はHPの数字もじわっと減らします。

function animateNumber(element, fromValue, toValue, durationMs) {
  const diff = toValue - fromValue;
  const startTime = performance.now();

  function tick(now) {
    const progress = Math.min((now - startTime) / durationMs, 1); // 0 → 1
    const eased = 1 - Math.pow(1 - progress, 2);                  // ease-out
    element.textContent = Math.round(fromValue + eased * diff);
    if (progress < 1) requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
}

requestAnimationFrame で60fpsの滑らかなアニメーションを作ります。1 - Math.pow(1 - progress, 2) の式は ease-out(最初速くて最後ゆっくり) という動きを表す数式。これがあるだけで「機械的に数字が変わる」のではなく「自然に減っていく」見え方になります。

攻撃エフェクトを4タイプ作ろう(パーティクル)

タイプごとに違うエフェクトを表示します。画像なしで、divとCSSアニメーションだけで作ります。

共通の仕組み:エフェクトオーバーレイ

エフェクトは #effect-overlay という専用のレイヤーに、divをたくさん追加して描きます。終わったら innerHTML = ''で全部消すだけ。

async function showAttackEffect(moveType, targetSide) {
  effectOverlay.innerHTML = '';
  const center = EFFECT_CENTERS[targetSide];
  const duration = moveType === 'normal' ? 560 : 1320;

  if (moveType === 'fire')       createFireEffect(center);
  else if (moveType === 'water') createWaterEffect(center);
  else if (moveType === 'grass') createGrassEffect(center);
  else                            createNormalEffect(center);

  await sleep(duration);
  effectOverlay.innerHTML = ''; // ← 後片付け
}

ほのお:14個の炎が立ち上る

function createFireEffect(center) {
  const PARTICLE_COUNT = 14;

  for (let i = 0; i < PARTICLE_COUNT; i++) {
    const particle = document.createElement('div');
    Object.assign(particle.style, {
      position: 'absolute',
      width: `${11 + Math.random() * 19}px`,
      height: /* ... */,
      background: `radial-gradient(ellipse at 50% 80%, ${innerColor}, ${outerColor})`,
      borderRadius: '50% 50% 30% 30%',
      filter: 'blur(1px)',
      '--fx-drift': `${(Math.random() - 0.5) * 90}px`,
      animation: `fx-fire-rise ${durationS}s ease-out ${delayS}s both`,
    });
    effectOverlay.appendChild(particle);
  }
}

CSSのほうにアニメーションを定義しておきます。

@keyframes fx-fire-rise {
  0%   { transform: translateY(0)      translateX(0)                  scale(1);    opacity: 0.95; }
  100% { transform: translateY(-210px) translateX(var(--fx-drift, 0)) scale(0.12); opacity: 0;    }
}

ポイントは --fx-drift というCSSカスタムプロパティ。JavaScriptから '--fx-drift': '32px' のように設定すると、CSS側の var(--fx-drift) で受け取れます。これで「全部の炎が違う方向に流れる」ランダム性が出せます。

みず:5枚の波紋が広がる

function createWaterEffect(center) {
  for (let i = 0; i < 5; i++) {
    const ripple = document.createElement('div');
    Object.assign(ripple.style, {
      width: `${55 + i * 6}px`,
      border: '3px solid #64b5f6',
      borderRadius: '50%',
      animation: `fx-water-ripple 0.9s ease-out ${i * 0.18}s both`,
    });
    effectOverlay.appendChild(ripple);
  }
}

5枚の同心円を0.18秒ずつ遅延させて発生させると、波紋が連続的に広がっていく演出になります。

@keyframes fx-water-ripple {
  0%   { transform: scale(0.05); opacity: 0.9; }
  100% { transform: scale(4.8);  opacity: 0;   }
}

scale だけで小さい円を4.8倍に拡大するアニメ。opacity も同時に減らすと「広がりながら消える」表現になります。

くさ:16枚の葉っぱが回転しながら降る

function createGrassEffect(center) {
  for (let i = 0; i < 16; i++) {
    const leaf = document.createElement('div');
    Object.assign(leaf.style, {
      background: greens[Math.floor(Math.random() * greens.length)],
      borderRadius: '50%',
      '--fx-rot-s': `${Math.random() * 360}deg`,
      '--fx-rot-e': `${rotationStart + (Math.random() > 0.5 ? 210 : -210)}deg`,
      animation: `fx-leaf-fall ${durationS}s ease-in ${delayS}s both`,
    });
    effectOverlay.appendChild(leaf);
  }
}

fx-leaf-fall は「下に落ちながら回転する」アニメ。回転方向と速度を1枚ごとにランダム化することで、本当の葉っぱが舞っているような自然さが出ます。

ノーマル:白い斬撃ライン

function createNormalEffect(center) {
  for (let i = 0; i < 4; i++) {
    const slash = document.createElement('div');
    Object.assign(slash.style, {
      width: '220px',
      height: '5px',
      background: 'linear-gradient(to right, transparent, #fff 30%, #fff 70%, transparent)',
      boxShadow: '0 0 14px rgba(255,255,255,0.9)',
      animation: `fx-slash 0.32s ease-out ${i * 0.07}s both`,
    });
    effectOverlay.appendChild(slash);
  }
}

両端を transparent にしたグラデーションで、光の筋っぽい見た目。それを4本、少しずつタイミングをずらして走らせるだけで「シュバババッ」という連撃感が出ます。

🎨 タイプ別エフェクトまとめ

タイプ

動き

ほのお

下から上へ立ち上り消える

14個

黄・オレンジ・赤

みず

同心円が拡大して消える

5枚 + 中心フラッシュ

水色

くさ

回転しながら落下

16枚

緑系5色

ノーマル

横に走る斬撃ライン

4本

全部を「div + CSSアニメ + ランダム化」の組み合わせで作っています。1つマスターすれば、雷・氷・毒……何でも追加できます。

画面シェイクと点滅で被弾を演出しよう

エフェクトだけだと「攻撃した感」しか出ません。「被弾した感」を出すには、被弾側にも演出が必要です。

画面シェイク

@keyframes screen-shake {
  0%   { transform: translateX(0);    }
  18%  { transform: translateX(-6px); }
  36%  { transform: translateX(6px);  }
  54%  { transform: translateX(-4px); }
  72%  { transform: translateX(4px);  }
  90%  { transform: translateX(-2px); }
  100% { transform: translateX(0);    }
}
.battle-shake {
  animation: screen-shake 0.35s ease-out forwards;
}

CSSでアニメを定義しておいて、JavaScriptからクラスを付け外しするだけ。

function shakeScreen() {
  battleField.classList.remove('battle-shake');
  void battleField.offsetWidth;            // ← この一行が大事!
  battleField.classList.add('battle-shake');
  setTimeout(() => battleField.classList.remove('battle-shake'), 400);
}

🔑 void battleField.offsetWidth; ってナニ?

これは「強制リフロー」と呼ばれるテクニックです。「同じクラスをもう一度つけてもアニメをやり直したい」ときの裏ワザ。

offsetWidth を読むだけで、ブラウザは「えっ、要素のサイズ知りたいの? じゃあ今すぐ計算しなきゃ」と、それまで保留していた変更を全部反映します。これで「クラスを外した」が確定するので、再度つけたときにちゃんとアニメが最初から再生されます。

これがないと、連続でシェイクが起きたときに2回目以降がアニメしないバグになります。

スプライトの点滅

async function showFlash(side) {
  const sprite = side === 'player' ? playerSprite : enemySprite;
  for (let i = 0; i < 3; i++) {
    sprite.style.opacity = '0.07';
    await sleep(100);
    sprite.style.opacity = '1';
    await sleep(100);
  }
}

「ほぼ透明 → 不透明」を3回繰り返すだけ。await sleep(100)0.1秒待つを間に挟むだけでチカチカ点滅が表現できます。

シェイクと点滅は同時に

function showDamageEffects(side) {
  shakeScreen();          // 揺れ開始(待たない)
  return showFlash(side); // 点滅は待つ
}

shakeScreen() は呼びっぱなしで戻り値を待たず、showFlash() だけ return しています。こうすると揺れは画面全体に走りつつ、スプライトはピカピカ点滅という同時進行が実現できます。

Web Audio APIでBGMと効果音をコードだけで作ろう

ロックマン記事でも触れた Web Audio API 。今回はBGMもループさせます。

AudioContextの初期化

ブラウザの仕様で、ユーザーがクリックする前に音を鳴らすことは禁止されています。だから最初のクリックで初期化します。

let audioCtx = null;

document.addEventListener('click', () => {
  if (!audioCtx) initAudio();
  else if (audioCtx.state === 'suspended') audioCtx.resume();
});

function initAudio() {
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  // ...
}

基本の音再生関数

function playTone(frequency, duration, waveType = 'square', volume = 0.18, delaySeconds = 0) {
  const startTime = audioCtx.currentTime + delaySeconds;
  const oscillator = audioCtx.createOscillator();
  const gainNode = audioCtx.createGain();

  oscillator.connect(gainNode);
  gainNode.connect(audioCtx.destination);

  oscillator.type = waveType;
  oscillator.frequency.setValueAtTime(frequency, startTime);

  // エンベロープ: ふわっと上がってふわっと下がる
  gainNode.gain.setValueAtTime(0.001, startTime);
  gainNode.gain.linearRampToValueAtTime(volume, startTime + 0.012);
  gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + duration);

  oscillator.start(startTime);
  oscillator.stop(startTime + duration + 0.05);
}

音は「波形 → 音量調整 → 出力先」とつないで作ります。

  • oscillator.type'square'(矩形波)にすればファミコン風。
  • 'sine' にすると柔らかい音。
  • 'sawtooth' にするとジリジリした音。

タイプ別の攻撃音

function playAttackSound(moveType) {
  if (moveType === 'fire') {
    playTone(880, 0.14, 'sawtooth', 0.18);  // ジリジリ
    playTone(1100, 0.10, 'sawtooth', 0.13, 0.09);
  } else if (moveType === 'water') {
    playTone(440, 0.22, 'sine', 0.22);       // 柔らかく流れる
    playTone(554, 0.18, 'sine', 0.16, 0.12);
  } else if (moveType === 'grass') {
    playTone(660, 0.13, 'square', 0.18);     // シャキッと切れる
    playTone(784, 0.10, 'square', 0.13, 0.09);
  } else {
    playTone(520, 0.09, 'square', 0.24);     // スパッと打撃
    playTone(390, 0.07, 'square', 0.19, 0.06);
  }
}

タイプごとに波形と周波数を変えるだけで、全然違う印象の音になります。これが音の不思議。

ダメージ音はノイズで作る

function playDamageSound() {
  // (1) 低音がピッチを下げながら鳴る
  oscillator.frequency.setValueAtTime(210, startTime);
  oscillator.frequency.exponentialRampToValueAtTime(50, startTime + 0.18);

  // (2) ノイズで衝撃感を補強
  const noiseLen = Math.floor(audioCtx.sampleRate * 0.10);
  const noiseBuffer = audioCtx.createBuffer(1, noiseLen, audioCtx.sampleRate);
  const noiseData = noiseBuffer.getChannelData(0);
  for (let i = 0; i < noiseLen; i++) {
    noiseData[i] = Math.random() * 2 - 1;  // -1〜+1のランダム
  }
  // ... ノイズを再生
}

「ズドン」という音は 音程ありの低音 + ノイズ の合体で作ります。これがロックマン記事と同じテクニック。

ループBGMをスケジューラーで作る

これが今回の記事の音楽でいちばん面白いところです。BGMをループさせるには、先読みで音を予約していく仕組みが必要です。

function scheduleBgm() {
  while (bgmNextScheduleTime < audioCtx.currentTime + 0.15) {
    const step = bgmCurrentStep;

    // メロディ・ハーモニー・ベース・ドラム を予約
    if (MELODY[step])    playBgmNote(MELODY[step], stepDuration, 'square', 0.10, bgmNextScheduleTime);
    if (BASS[step])      playBgmNote(BASS[step],   stepDuration, 'triangle', 0.18, bgmNextScheduleTime);
    if (KICK[step])      playBgmKick(bgmNextScheduleTime);
    // ...

    bgmNextScheduleTime += stepDuration;
    bgmCurrentStep = (bgmCurrentStep + 1) % 32; // 32ステップでループ
  }
}

bgmSchedulerTimer = setInterval(scheduleBgm, 50); // 50msごとに先読み

🎵 なぜ「先読み」が必要?

setInterval正確な時刻を保証しません。「100ms後に呼んで」と頼んでも、実際は103msだったり98msだったりブレます。これでは音楽がガタガタになる。

そこで、AudioContextの内部時計(audioCtx.currentTime)を使って「数百ms先の音を、正確な時刻指定で予約」します。setInterval がブレても、予約してある時刻通りに音が鳴るので、リズムが乱れません。

これは Chris Wilson さんの「A Tale of Two Clocks」 という有名な記事で広まった、Web Audio API のリズム再生のお作法です。プロのWeb音楽アプリも全部このパターン。

メロディ・ベース・ドラムの音色使い分け

パート

波形

音域

役割

メロディ

矩形波(square)

高音

ピロピロした主旋律

ハーモニー

矩形波

中音

メロディの厚み

ベース

三角波(triangle)

低音

リズムの土台

キック

サイン波(200→40Hz)

超低音

ドン!

スネア

ノイズ

全帯域

タンッ!

ハイハット

短いノイズ

高帯域

チチチ

矩形波 + 三角波 + ノイズ。この3つだけでファミコン音源は作れるんです。ピコピコサウンドの正体はとても素朴です。

完成コード全文

script.js(完成版)

コード全文が非常に長いため、以下のリンクからダウンロードしてください。

👉 完成コードをダウンロードする

全コードをコピーして script.js という名前で保存すればOKです。

記事の中で解説した各セクションが、コメント付きで整理されています:

script.js の構成
├── 1. ゲーム設定(モンスター・タイプ相性・座標)
├── 2. ゲームの状態(ステートマシン)
├── 3. DOM参照
├── 4. Web Audio API の準備
├── 5. 音を作る低レベル関数
├── 6. シーン別の効果音
├── 7. BGMのデータ(メロディ・ベース・ドラム)
├── 8. BGMのスケジューラー
├── 9. ユーティリティ(sleep / deepCopy / animateNumber)
├── 10. メッセージ表示(タイプライター)
├── 11. パネル切替・HPバー
├── 12. ダメージ計算・タイプ相性
├── 13. 攻撃エフェクト(炎・水・草・斬撃)
├── 14. ダメージ・登場・敗北アニメーション
├── 15. 攻撃 → 1ターン → バトル全体の進行
└── 16. UI制御 と イベントリスナー

動かしてみよう

3つのファイルを保存したら、index.html をダブルクリックしてブラウザで開いてみましょう。

操作方法:

  • ページが開くと自動的にバトル開始
  • 「たたかう」*ボタンをクリック → 4つの技ボタンが出る
  • 技をクリック → 自分が攻撃 → 敵が反撃 → 1ターン終了
  • HPが0になったらバトル終了
  • 「もういちどたたかう」*でリプレイ

もし画面が真っ黒のままだったり、エラーが出る場合は以下をチェックしてみてください:

  • 3つのファイルが同じフォルダに入っているか?
  • ファイル名は index.htmlstyle.cssscript.js になっているか?
  • ブラウザの開発者ツール(F12キー)のConsoleタブにエラーが出ていないか?
  • 音が鳴らない場合:画面を一度クリックしてみてください(ブラウザの自動再生制限のため)

次のステップ

バトルゲームが動いたら、次はカスタマイズに挑戦してみましょう!自分でコードをいじって遊ぶのが、プログラミング上達の一番の近道です。

チャレンジアイデア

  • 新しいタイプを追加するTYPE_CHARTelectric(でんき)や ice(こおり)を追加。タイプ相性を考えて配置すると面白い
  • モンスターを増やすPLAYER_BASE のように MONSTER_BASE_CMONSTER_BASE_D を作って、ランダムに敵が出てくるようにする
  • 新しい攻撃エフェクトを作る:雷タイプならジグザグの白い線、こおりタイプなら結晶が降り注ぐ……など
  • 状態異常を追加する:「やけど」「マヒ」みたいに、毎ターンHPが減ったり技が出なかったりする仕様
  • アイテム機能を実装する:「どうぐ」ボタンを有効化して、回復薬を使えるようにする
  • 逃げる機能を実装する:「にげる」ボタンを有効化して、確率で逃げられるようにする
  • モンスターチェンジ:複数の手持ちから選んで戦う仕組み(本家ポケモンっぽい)
  • AIを賢くする:相性の良い技を優先する、HPが低いときは温存する、など
  • 戦闘背景を切り替える:草原・海・洞窟など、CSSのグラデーションを変えるだけで雰囲気が一変
  • モンスターを自分でデザインする:CSSのスプライト部分をいじって、自分だけのモンスターを描いてみよう

わからないことがあったら「JavaScript ○○」「CSS アニメーション ○○」で検索してみよう。今回作ったものはHTML + CSS + JavaScriptの集大成なので、Webの基礎を一通り抑えられたことになります。

ポケモン風のバトルゲームが作れたら、もうあなたは「ゲームを遊ぶ側」から「ゲームを作る側」に回れています。次はRPG、カードゲーム、対戦ゲーム……世界はずっと広いです。

野澤嘉孝

この記事を書いた人

野澤 嘉孝

ソフトウェアエンジニア。同志社大学理工学部を経て、京都大学大学院で核融合発電の基礎研究(プラズマ物理)に従事。在学中は高校生向け数学塾の講師を、大学院では大学生に対して物理実験の授業を担当し、延べ500名以上の学生をサポート。現在は業界特化型SaaSの開発に携わりながら、中高生向けプログラミングスクール Sandbox(サンドボックス)の運営を行う。

"自分で作れた"が生まれるスクール、Sandbox

Sandboxについて詳しく見る > すぐに相談したい方は → 無料面談に申し込む まずは気軽に → Peatixで無料体験イベントを見る(オンライン・無料)

入会金無料・いつでも退会OK・退会後もいつでも再入会できます