[JavaScript] Three.jsでRPGバトルをゼロから組む|フェーズ管理・UI・アニメーション・SEまで

はじめに

今日は、バトルモードの作成を途中まで作ったので、その実装メモです。

バトルモード用のクラスを作成し、エンカウント、敵の出現時に上空から落下させ過去作成した「snapToGround」で地面に貼りつき、フェードイン表示。
その後、先日実装したコマンドUIを表示して、コマンド選択後にアクションを起こすという所までやってます。

絵的にはシンプルですが、バックでは結構な量のコードを書いてます。

BGMは交換ラボ / springin でお借りしています。

実装内容

  • エンカウント管理
  • Battle クラス新規設計
  • フェーズ制御(enter / fade / command / action / escape)
  • 敵の出現演出(落下+地面スナップ)
  • マテリアル透過フェード
  • コマンドUI統合
  • プレイヤー行動アニメーション
  • deltaベースの移動・待機
  • 効果音・BGM管理
  • 戦闘終了処理
  • ダメージUIの下地

1. フィールド→バトル遷移の考え方

RPGのバトル実装で最初に悩むのは、 「敵をどう強くするか」でも 「コマンドをどう作るか」でもなく、

フィールドから、どうやって自然にバトルへ入るか という部分だと思う。

今回は以下の前提で設計した。

  • フィールドとバトルは同じ scene を使う
  • シーン切り替えはしない
  • 状態だけを切り替える

つまり「場面遷移」ではなく **状態遷移(state transition)**としてバトルを扱う。


RPG.state で世界の状態を分ける

まず、ゲーム全体の状態を一つの値で管理する。

RPG.state = 'field'; // or 'battle'

これだけで、

  • 今はフィールドなのか
  • 今はバトル中なのか

を、どこからでも判定できる

重要なのは 「バトル専用の update を作る」ことではなく、 update の中で状態によって処理を分けること。

export function updateRPGBattle(delta) {
  if (RPG.state === 'field') updateEncounter(delta);
  if (RPG.state === 'battle') {
    RPG.battle.update(delta);

    if (RPG.battle.isEnd()) {
      RPG.battle.exit();
      resetFieldState();
    }
  }
}

この構造にすると、

  • フィールド処理
  • バトル処理

同じ更新ループの中で共存できる。


エンカウントは「時間」で管理する

今回は、敵シンボル接触ではなく 時間経過によるランダムエンカウントを採用した。

function updateEncounter(delta) {
  RPG.encounterTime += delta;

  if (RPG.encounterTime >= RPG.nextEncounterTime) {
    enterBattle();
  }
}

ここで重要なのは、

  • 毎フレーム乱数を引かない
  • あらかじめ「次の閾値」を決めておく

という点。

function resetEncounterTime() {
  RPG.nextEncounterTime =
    RPG.encounterTimeMax + (Math.random() * 20 - 10);
}

こうすると、

  • 毎回タイミングが少しずれる
  • でも完全ランダムにはならない
  • プレイヤーに「予測不能すぎる」印象を与えない

という、RPGらしい間隔になる。


バトル開始は「Battle クラスを生成するだけ」

エンカウント成立時にやることは、とてもシンプル。

function enterBattle() {
  RPG.state = 'battle';
  RPG.battle = new Battle();
  RPG.battle.enter();
}
  • state を battle に変える
  • Battle クラスを作る
  • enter() を呼ぶ

それ以上のことは、ここではやらない。

敵の生成も、UI表示も、演出も、 全部 Battle クラスの責務に閉じ込める。

これが後で効いてくる。


「切り替えない」ことで楽になる

シーン切り替えをしない設計にしたことで、

  • カメラを引き継げる
  • プレイヤー位置をそのまま使える
  • 地形や傾斜をそのまま活かせる
  • VR対応時も破壊的変更が少ない

というメリットがある。

バトルは 「別世界に行く」のではなく 同じ世界で、ルールだけが変わる

この感覚で設計すると、 後続の実装が驚くほど楽になる。


この次の章では、 Battle クラスの中で、どうやって処理を整理したか ──フェーズ管理(enter / fade / command / action)について書く。

2. フェーズ駆動バトル設計(switch)

バトル実装で次に考えたのは、 **「バトル中の流れをどう整理するか」**という点だった。

やりたいことを並べると、

  • 敵が出現する
  • 演出が終わる
  • コマンドを選ぶ
  • 行動が起きる
  • またコマンドに戻る/終了する

この一連の流れは、 実は 状態が時間とともに切り替わるだけ だ。

そこで、バトル全体を フェーズ(phase) という概念で分割することにした。


Battle.phase で「今どこか」を明示する

Battle クラスの中に、現在の状態を持たせる。

this.phase = 'enter';
// enter | fade | command | action | enemy | escape

これだけで、

  • 今は敵出現中なのか
  • コマンド待ちなのか
  • 行動中なのか

が一目で分かる。

重要なのは フラグを複数持たないこと

「isEntering」「isCommand」「isAction」 のように増やすと、必ず破綻する。


update() はフェーズ分岐だけを書く

Battle.update() では、 細かい処理を直接書かない。

やるのは、

  • 今のフェーズを判定
  • 必要な処理を呼ぶ
  • 次のフェーズへ進めるか決める

これだけ。

update(delta) {
  if (!this.enemy) return;

  switch (this.phase) {
    case 'enter':
      if (this.enemyDrop(delta)) this.phase = 'fade';
      break;

    case 'fade':
      if (this.fadeIn(delta)) this.phase = 'command';
      break;

    case 'command':
      this.commandUI.show();
      this.inputCommand();
      break;

    case 'action':
      // プレイヤー行動
      break;

    case 'escape':
      this.isFinished = true;
      break;
  }
}

この形にすると、

  • 上から読むだけで流れが分かる
  • フェーズ追加が簡単
  • デバッグ時に「今どこ?」が即分かる

という利点がある。


if の羅列ではなく switch を使った理由

最初は if 文を並べる形も考えた。

if (this.phase === 'enter') { ... }
if (this.phase === 'fade') { ... }

ただ、バトルフェーズは 同時に複数成立しない(排他的)

この場合、switch を使うと、

  • 排他性が構文で保証される
  • 意図しない二重実行が起きない
  • 読み手に「状態機械」だと伝わる

結果として、 コード量は少し増えるが、理解コストが下がる

今回はこの点を優先した。


フェーズ遷移は update 側に残す

例えばフェード処理。

case 'fade':
  if (this.fadeIn(delta)) this.phase = 'command';
  break;

fadeIn() の中で phase を書き換えず、 update 側で遷移を書く

理由は単純で、

  • 処理(演出)
  • 判断(次に進むか)

を分けたかったから。

こうしておくと、

  • フェード後にSEを鳴らす
  • カメラ演出を挟む
  • 分岐条件を変える

といった変更が、update() だけで済む。


フェーズ設計は「後で効く」

このフェーズ駆動設計にしておくと、

  • 敵ターン追加
  • 魔法演出
  • 勝利演出
  • 敗北演出

case を1つ足すだけ で拡張できる。

実装を進めるほど、 「ここに書けばいい」という場所が明確になる。

バトルが複雑になっても破綻しにくいのは、 この段階で 流れを状態として固定した からだ。


次の章では、 敵をどうやって“バトルらしく出現させたか” ──落下・地面スナップ・フェード演出について書く。

3. 敵出現演出(落下+フェード)

バトルに入った瞬間、 いきなり敵が「ポン」と立っていると、どうしても味気ない。

今回は、

  • シーン切り替えなし
  • 同じフィールド上で戦闘開始

という設計なので、 **敵の出現演出がそのまま“バトル開始演出”**になる。

そこで採用したのが、

上空から落下 → 地面に着地 → フェードイン

という、シンプルだけど分かりやすい流れだ。


敵は「生成」しない

まず重要なのは、 バトル開始時に 敵モデルを新しく生成していない こと。

敵はすでに scene 内に存在しており、

const enemies = config.scene.children.filter(
  (obj) => obj.userData.role === 'enemy'
);
this.enemy = enemies[0];

という形で取得している。

  • モデルロード済み
  • 当たり判定も存在
  • 普段は非表示 or 非アクティブ

この状態から、

  • 位置を動かす
  • 表示する
  • 演出をかける

だけにしている。

これにより、

  • 生成コストがかからない
  • ロード待ちが発生しない
  • フィールドとバトルで同一オブジェクトを使える

というメリットがある。


出現位置は「プレイヤー正面」

敵の出現位置は、 プレイヤーの向いている正面方向を基準に決めた。

const forward = new THREE.Vector3(0, 0, 1)
  .applyQuaternion(this.player.quaternion)
  .normalize();

this.enemy.position
  .copy(this.player.position)
  .add(forward.multiplyScalar(6));

こうしておくと、

  • 常に視界に入る位置に出る
  • カメラ調整が不要
  • VRでも破綻しにくい

という利点がある。

その後、Y座標だけを持ち上げておく。

this.enemy.position.y += 12;
this.isLanding = true;

落下処理は update で行う

落下処理は enemyDrop() に切り出している。

enemyDrop(delta) {
  if (this.isLanding) {
    this.enemy.position.y -= delta * 10;

    if (snapToGround(this.enemy.position, this.groundMeshes, 1, 1)) {
      this.enemy.lookAt(this.player.position);
      this.isLanding = false;
      this.enemy.visible = true;
      return true;
    }
  }
  return false;
}

ポイントは、

  • delta ベース
  • Raycaster で地面スナップ
  • 着地判定を true / false で返す

enemyDrop() 自体は、

「落下が終わったかどうか」

だけを返す。

次のフェーズへ進むかどうかは、 update() 側で判断する。


着地と同時にフェードイン準備

着地した瞬間に、 敵はまだ完全表示しない。

まずマテリアルを透過状態にする。

this.enemy.material.forEach((mat) => {
  mat.transparent = true;
  mat.depthWrite = false;
  mat.opacity = 0;
});

ここで重要なのは、

  • depthWrite を false にする
  • SkinnedMesh 対策で 配列対応にする

これをしないと、

  • 奥行きがおかしくなる
  • モデルが変な色になる
  • フェードが破綻する

といった問題が起きやすい。


フェードもフェーズで管理する

フェード処理は fadeIn() に切り出している。

fadeIn(delta) {
  this.fade.t += delta;
  const p = Math.min(this.fade.t / this.fade.duration, 1);

  this.enemy.material.forEach((mat) => {
    mat.opacity = p;
  });

  if (p >= 1) {
    this.enemy.material.forEach((mat) => {
      mat.transparent = false;
      mat.depthWrite = true;
    });
    return true;
  }
  return false;
}

ここでも、

  • delta ベース
  • 完了判定を boolean で返す

という形を崩していない。

update() 側では、

case 'fade':
  if (this.fadeIn(delta)) this.phase = 'command';
  break;

と書くだけで済む。


なぜ「落下+フェード」なのか

この演出の良い点は、

  • フィールドと自然につながる
  • カメラを切り替えなくていい
  • VRでも違和感が出にくい
  • 実装が軽い

こと。

派手な演出を入れなくても、 **「今から戦闘が始まる」**という情報は十分伝わる。


次の章では、 バトル中の入力をどう整理したか ──コマンドUIと入力分離について書く。

4. コマンドUIと入力分離

バトル実装で意外とハマりやすいのが、 「UI」と「入力」と「ゲームロジック」が混ざる問題だ。

最初はつい、

  • UIの中でキー入力を見る
  • 入力処理の中で状態を変える
  • あちこちから UI を show / hide する

という書き方になりがちだが、 これをやると後で必ず破綻する。

今回は最初から、

UIは描画だけ、判断はBattle側

という方針で分離した。


CommandUI は「表示装置」に徹する

CommandUI クラスは、 以下の責務だけを持つ。

  • コマンド一覧を描画する
  • 選択インデックスを管理する
  • show / hide する
  • moveUp / moveDown する

つまり、

「どう見せるか」だけ

を担当する。

this.commandUI = new CommandUI(config.camera, 'battle');

CommandUI 自体は、

  • RPG.state を知らない
  • Battle.phase を知らない
  • 戦闘中かどうかも知らない

この「無知さ」が重要。


入力は Battle 側で処理する

キー入力はすべて Battle 側で受け取る。

case 'command':
  this.commandUI.show();
  this.inputCommand();
  break;

inputCommand() の中では、

  • キー入力を見る
  • UIを操作する
  • 決定時に意味処理へ渡す

だけを行う。

inputCommand() {
  if (consumeKey('ArrowUp')) {
    this.commandUI.moveUp();
    this.commandUI.drawCommands();
  }

  if (consumeKey('ArrowDown')) {
    this.commandUI.moveDown();
    this.commandUI.drawCommands();
  }

  if (consumeKey('Enter')) {
    this.selectedCommand = this.commandUI.getSelectedCommand();
    this.commandUI.hide();
    this.selectCommand();
  }
}

CommandUI 自身は、 「Enterが押された」ことすら知らない。


コマンドの意味はさらに分離する

選ばれたコマンドの処理は、 selectCommand() にまとめている。

selectCommand() {
  switch (this.selectedCommand) {
    case 'たたかう':
      this.phase = 'action';
      break;

    case 'にげる':
      this.phase = 'escape';
      break;
  }
}

ここでは、

  • UI操作はしない
  • 入力も見ない
  • 意味だけを書く

というのがポイント。


なぜここまで分けるのか

この分離をしておくと、

  • UIをVR用に差し替える
  • 入力をコントローラに変える
  • コマンド数を増やす
  • AI操作に切り替える

といった変更が、 ほぼ Battle クラスだけで完結する。

逆に、UIが入力やロジックを知っていると、 修正が芋づる式に広がる。


フィールドUIとバトルUIの衝突を防ぐ

この設計のおかげで、 以下の問題も自然に解決できた。

  • バトル中に ESC を押すと
  • フィールド用メニューが開いてしまう

対策は単純で、

if (RPG.state === 'battle') return;

と、フィールドUI側でガードするだけ。

UIの責務を分けていたからこそ、 状態で素直に制御できた


UIは「操作対象」ではなく「結果表示」

今回の設計で一番大きかったのは、

UIは操作するものではなく 操作の結果を表示するもの

という意識に切り替えられたことだ。

この考え方にしておくと、 UI実装が一気に楽になる。


次の章では、 action フェーズで何が起きているか ──プレイヤー移動アニメーションと待ち時間管理について書く。

5. actionフェーズでの移動アニメ

コマンドを選んだあと、 何も起きずに結果だけ返ってくると、バトルは一気に味気なくなる。

そこで今回は、 ダメージ計算や数値処理より先に、

「行動している感」を出すための移動アニメーション

を action フェーズに入れた。


actionフェーズの役割を明確にする

action フェーズでやることは、かなり限定している。

  • プレイヤーが前に踏み込む
  • 攻撃が当たった瞬間を作る
  • 少し待ってから元の位置に戻る
  • 次のフェーズへ戻す

ここでは数値処理をしない。

演出だけに集中する。


行動開始時に「attack状態」を作る

コマンド確定時に、 actionフェーズ用の一時データを作る。

this.attack = {
  t: 0,              // 経過時間
  duration: 0.5,     // 前進時間
  wait: 0,           // 待機時間
  waitDuration: 0.3, // ヒット後の余韻
  start: this.player.position.clone(),
  target: this.enemy.position.clone(),
  finished: false,
};

ここで重要なのは、

  • 座標を clone() で保存する
  • 毎フレーム計算し直さない

という点。


移動は lerp + delta で制御する

action フェーズ中の前進処理は、 毎フレーム update() 内で行う。

this.attack.t += delta;
const p = Math.min(this.attack.t / this.attack.duration, 1);

この p を使って、 開始位置から攻撃位置まで補間する。

const dir = this.attack.target
  .clone()
  .sub(this.attack.start)
  .normalize();

const attackPos = this.attack.target
  .clone()
  .add(dir.multiplyScalar(-1.2));

const pos = this.attack.start.clone().lerp(attackPos, p);
this.player.position.copy(pos);

ポイントは、

  • 敵のど真ん中には行かない
  • 少し手前で止める
  • 距離ではなく時間で制御する

RPGでは、 「何秒で踏み込むか」のほうが重要になる。


アニメーション更新は action 中だけ

移動中だけアニメーションを更新する。

config.player.mixer.update(config.player.animeSpeed);
  • フィールド移動とは別管理
  • action フェーズに閉じ込める

これで、

  • コマンド待ち中は静止
  • 行動中だけ動く

という切り替えが自然にできる。


ヒット後の「待ち時間」を入れる

移動が完了した瞬間に戻ると、 どうしても軽く見える。

そこで、

  • 前進完了
  • 効果音再生
  • 少し待つ
  • 元の位置へ戻る

という流れを作る。

if (p >= 1) {
  sound('./assets/sound/ナイフで切る.mp3');
  this.attack.finished = true;
}
// 待ち時間
this.attack.wait += delta;
if (this.attack.wait >= this.attack.waitDuration) {
  this.player.position.copy(this.attack.start);
  this.phase = 'command';
}

ここでも setTimeout は使わない。

待ち時間もゲーム状態の一部として、 delta ベースで管理する。


なぜ setTimeout を使わないのか

update() は毎フレーム呼ばれる。

その中で setTimeout を使うと、

  • タイマーが多重に登録される
  • フェーズ遷移が壊れる
  • デバッグが困難になる

action フェーズ自体が **「時間を扱うための箱」**なので、 時間管理はすべてここに閉じ込める。


最低限でも「戦闘らしさ」は出る

この時点で実現できているのは、

  • コマンド選択
  • プレイヤーが踏み込む
  • 攻撃音が鳴る
  • 少し間があって戻る
  • 次のコマンドを選べる

数値処理が無くても、 バトルとして成立している

演出を先に作ることで、

  • どこにダメージ表示を入れるか
  • どの瞬間が「ヒット」か

も自然に見えてくる。


次の章では、 効果音・BGMをどう扱ったか ──音が入った瞬間に何が変わったかについて書く。

6. setTimeoutを使わない理由

actionフェーズで「少し待つ」「間を作る」という話をすると、 最初に思いつくのが setTimeout() だと思う。

実際、実装途中で一度はこう書きたくなる。

setTimeout(() => {
  this.phase = 'command';
}, 500);

しかし今回は、意図的に setTimeout を使わなかった

理由は単なる好みではなく、 ゲームループと相性が悪いからだ。


update() は「毎フレーム呼ばれる世界」

three.js の update は、 基本的に毎フレーム呼ばれる。

update(delta) {
  // ここは 1秒間に60回前後 実行される
}

この中で setTimeout を書くと、 条件を満たしているフレームすべてで タイマーが登録され続ける

if (p >= 1) {
  setTimeout(() => {
    this.phase = 'command';
  }, 500);
}

このコードは一見正しそうだが、

  • p >= 1 のフレームが複数回ある
  • そのたびに setTimeout が積まれる
  • 0.5秒後に複数回コールバックが走る

という状態になる。

結果として、

  • フェーズが何度も切り替わる
  • 状態が壊れる
  • デバッグが非常に困難になる

setTimeout は「フレーム外の時間」

setTimeout は、

  • ブラウザのイベントループ
  • JS のタスクキュー

に依存する。

一方で、ゲームのロジックは、

  • フレーム
  • delta
  • update ループ

に依存している。

この 時間軸のズレ が問題になる。


解決策は「待ち時間も状態に含める」

今回は、待ち時間を attack オブジェクトの状態として持たせた。

this.attack = {
  t: 0,
  duration: 0.5,
  wait: 0,
  waitDuration: 0.3,
  finished: false,
};

そして update() 内で、 delta を足していく。

this.attack.wait += delta;
if (this.attack.wait >= this.attack.waitDuration) {
  this.phase = 'command';
}

これで、

  • フレームと完全同期
  • 多重実行が起きない
  • 状態遷移が予測可能

になる。


「時間」もゲームの状態である

今回の実装で一番大きかった学びは、

時間も状態の一部として扱う

という考え方だった。

  • 攻撃中
  • ヒット後の余韻
  • 戻り待ち

これらはすべて 「時間によって変化する状態」。

だから、

  • フェーズ
  • サブ状態
  • 経過時間

として管理するほうが、 圧倒的に安全で分かりやすい。


setTimeout が悪いわけではない

誤解しないために補足すると、 setTimeout 自体が悪いわけではない。

  • UIの一時表示
  • 通知
  • 単発の非ゲーム処理

こういった用途では問題ない。

ただし、

  • キャラ移動
  • フェーズ遷移
  • アニメーション制御

のような ゲームループの中核 には向かない。


フェーズ駆動と相性が悪い理由

今回のバトルは、

  • フェーズ
  • delta
  • update

で世界が進んでいる。

この構造に setTimeout を混ぜると、

  • 状態管理が二重になる
  • 時間の基準が分裂する

結果として、 設計が一気に読みにくくなる。


結果として得られたもの

setTimeout を使わない設計にしたことで、

  • VR対応しても壊れない
  • フレームレートが変わっても安定
  • デバッグが楽
  • 演出を足しやすい

というメリットが得られた。


次の章では、 効果音・BGMを入れたことで何が変わったか ──音が「完成度」を一段引き上げた話を書く。

7. 音が入った瞬間にゲームになる話

ここまで実装してきて、 正直なところ「ロジックとしては動いている」状態にはなっていた。

  • フィールドからバトルに入る
  • 敵が出現する
  • コマンドを選べる
  • キャラクターが動く

でも、どこかまだ “デモっぽさ” が残っていた。

それが一気に変わったのが、 効果音とBGMを入れた瞬間だった。


音は「飾り」ではなく「合図」

最初に入れたのは、 攻撃が当たった瞬間の効果音。

if (p >= 1) {
  sound('./assets/sound/ナイフで切る.mp3');
  this.attack.finished = true;
}

これだけで、

  • どこがヒットなのか
  • 行動が成功したのか
  • 今フェーズが切り替わったのか

一瞬で分かる ようになった。

視覚よりも先に、 音が「イベントの境界」を教えてくれる。


コマンド操作音がテンポを作る

上下キーでの移動、決定時にも効果音を入れた。

sound('./assets/sound/決定ボタンを押す48.mp3');
sound('./assets/sound/決定ボタンを押す39.mp3');

これによって、

  • カーソル移動が軽く感じる
  • 入力にレスポンスがある
  • 迷ってもストレスが少ない

UI自体は変えていないのに、 操作感だけが一段良くなる


BGMは「状態」を切り替えるスイッチ

戦闘開始時に、BGMを再生する。

this.bgm = sound('./assets/sound/Short_mistery_014.mp3', { loop: true });

そして戦闘終了時に止める。

this.bgm.pause();
this.bgm.currentTime = 0;

これだけで、

  • フィールド
  • バトル

の境界が、 視覚よりも先に音で分かるようになる。

シーンを切り替えていないにもかかわらず、 「今は戦闘中だ」という感覚がはっきり生まれる。


なぜ音で一気に良くなるのか

理由は単純で、

  • 音は時間軸に直接作用する
  • フェーズ切り替えと相性がいい
  • 演出の成否を即座に伝える

今回の実装は、

  • フェーズ駆動
  • delta管理
  • 明確なヒットタイミング

という構造だった。

そこに音を乗せると、 設計していた境界がそのまま体感になる


音は「後から足す」ものではなかった

よく、

  • 最後に音を入れる
  • 仮実装が終わってから音を考える

と言われるが、今回の感触としては逆だった。

  • 音を入れた瞬間に完成度が跳ねる
  • ロジックの弱点も見える
  • 演出のタイミングが明確になる

音は飾りではなく、 設計の正しさを確認するための道具だった。


少ない音で十分だった

大量のSEや派手なBGMは入れていない。

  • コマンド移動音
  • 決定音
  • 攻撃ヒット音
  • 戦闘BGM
  • 逃走音

最低限これだけ。

それでも、

  • バトルらしさ
  • テンポ
  • 区切り

は十分に伝わる。


「動く」から「ゲームになる」瞬間

コード量が増えたわけでも、 新しい仕組みを入れたわけでもない。

音を入れただけで、

  • 操作が楽しくなり
  • 行動が意味を持ち
  • 状態遷移が自然に感じられる

この瞬間に、

あ、これはもうゲームだな

という感覚がはっきり来た。

ここまで来たら、 あとは演出や数値を足していくだけになる。