[JavaScript] Three.jsでRPGバトルを仕上げる|敵ターン追加・HP決着・テンポ調整・不具合潰し

はじめに

数日前にバトル実装として、「バトルに入る」「敵出現」「コマンドUI」「プレイヤー行動」までやりましたが、 今回は、そこに「敵ターン」「HP決着」「テンポ調整」「安全なフェーズ制御」を足して、遊べる最小構成まで仕上げたので、その実装メモです。

動画(パソコン)

概要

前回の記事では、Three.jsを使ってRPGバトルの基礎構造を実装した。 エンカウント管理、Battleクラスの導入、フェーズ駆動(enter / fade / command / action)、敵出現演出、コマンドUI、プレイヤーの攻撃アニメーションまでを扱った段階だった。

この時点で「バトルっぽい何か」は成立しているが、実際に遊ぶとすぐに限界が見えてくる。

  • 敵が反撃してこない
  • HPが減らないので勝敗が決まらない
  • 敵の攻撃が即時で、テンポが不自然
  • UIやSEが条件次第で多重発火する
  • 待機時間の指定が効いたり効かなかったりする

今回は、それらをすべて潰し、「最低限遊べるRPGバトル」と呼べる完成状態まで持っていく。

目的は拡張性の高い完成形ではない。 壊れず、連戦でき、理由なく破綻しない最小構成を作ることだ。


前回からの差分(今回やること)

今回追加・修正する内容は次の通り。

  • enemyフェーズを追加し、 プレイヤー → 敵 → プレイヤー のターンループを成立させる
  • HPを減らし、勝利・敗北・逃走でバトルが終了するようにする
  • 敵の攻撃に「走って近づく」演出を入れる (playerAction と対称な構造で実装する)
  • CommandUI.show()mBox.show() が 毎フレーム呼ばれてしまう問題をフラグで制御する
  • waitDuration の単位が 秒(delta) であることを整理し、 待機が効いたり効かなかったりする挙動を修正する

完成版バトルの到達点

この記事で到達するバトルの状態は次の条件を満たす。

  • フィールドを歩いているとエンカウントが発生する
  • 敵が出現し、演出付きで画面に現れる
  • コマンドUIから行動を選択できる
  • プレイヤーが攻撃すると敵のHPが減る
  • 敵ターンがあり、敵もプレイヤーを攻撃する
  • HPが0になると勝敗が決まり、バトルが終了する
  • バトル終了後、フィールドに戻って再度エンカウントできる

この条件を満たした時点で、 「RPGのバトルが一通り動く」状態になる。

以降の章では、実装を壊さないために重要だった設計判断と、 実際にハマりやすいポイントをコードと一緒に整理していく。

次は、enemyフェーズを追加して ターン制バトルを成立させるところから始める。

1. enemyフェーズを足して「ターン制」にする

前回の実装では、バトルの流れは次のようになっていた。

  • enter:敵出現準備
  • fade:敵出現演出
  • command:コマンド選択
  • action:プレイヤー行動

ここまでで「プレイヤーが攻撃する」ことはできるが、 敵の行動が存在しないため、バトルは実質的に一方通行だった。

今回まずやるのは、 enemyフェーズを追加してターン制を成立させることだ。


フェーズ構成の整理

完成版のフェーズ構成は次の通り。

enter → fade → command → action → enemy → command → …
                         escape / end

コード上では、phase に以下の値を持たせる。

phase: 'enter' | 'fade' | 'command' | 'action' | 'enemy' | 'escape' | 'end'

重要なのは フェーズ遷移を単純に保つこと。

  • プレイヤー行動が終わったら enemy
  • 敵行動が終わったら command
  • 勝敗が決まったら end にロック

これ以上複雑にしない。


update() に enemy フェーズを追加する

一番分かりやすいのは、Battle.update()switch を見ることだ。 差分だけ抜き出す。

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

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

    case 'fade':
      if (!this._appearMessageShown) {
        UI_CONFIG.mBox.show(
          this.json.description + ' があらわれた',
          { autoCloseTime: 3 }
        );
        this._appearMessageShown = true;
      }
      if (this.fadeIn(delta)) {
        this.phase = 'command';
        this._appearMessageShown = false;
        this._commandShown = false;
      }
      break;

    case 'command':
      if (!this._commandShown) {
        this.commandUI.show();
        this._commandShown = true;
      }
      this.inputCommand();
      break;

    case 'action':
      if (this.playerAction(delta)) {
        this.phase = 'enemy';
      }
      break;

    case 'enemy':
      if (this.enemyAction(delta)) {
        this._commandShown = false;
        this.phase = 'command';
      }
      break;

    case 'escape':
      if (!this._escaped) {
        playSe('Escape', config.sound);
        this._escaped = true;
      }
      this.isFinished = true;
      this.phase = 'end';
      break;
  }
}

ここでやっていることは非常に単純だ。

  • actiontrue を返したら enemy
  • enemytrue を返したら command へ戻す

update() 自体は判断をしない。 各フェーズの処理が「終わったかどうか」だけを見て、 次のフェーズへ進めている。


フェーズ完了を true / false で返す理由

playerAction()enemyAction() は、 内部でアニメーションや待機を行いながら、

  • まだ処理中 → false
  • 行動が完了 → true

を返す設計になっている。

この形にしておくと、

  • update() はフェーズ遷移だけを見る
  • 行動ロジックは各関数に閉じる

という責務分離が自然にできる。


終了時は phase と isFinished で完全に止める

バトル終了時は次の2つを同時に行う。

this.isFinished = true;
this.phase = 'end';

そして update の冒頭でガードする。

if (!this.enemy || this.isFinished) return;

これを入れておかないと、

  • 勝敗が決まった後も enemyAction が走る
  • SE や UI が多重発火する

といった「完成後に一番嫌なバグ」が発生する。

終了時は必ず二重ロックするのが安全だ。


この時点で、 プレイヤー → 敵 → プレイヤー というターンの骨格は完成する。

次の章では、 enemy フェーズの中身として HPの減算と勝敗判定を実装していく。

2. HPを実体に反映して勝敗を決める

enemyフェーズを追加してターンが回るようになっても、 HPが減らず、勝敗が決まらなければRPGとしては未完成だ。

ここでは createBattler() が作るRPGデータ(実体) に対して ダメージを適用し、バトルを終了させる仕組みを入れる。

Three.js のオブジェクトではなく、 RPG用のデータ構造にHPを持たせるのがポイントになる。


Battler 実体は「表示」と分離する

前回から使っている createBattler() は、 戦闘中に書き換えられる RPGデータの実体を作る関数だ。

function createBattler(base) {
  return {
    description: base.description,
    hp: base.hp,
    maxHp: base.hp,
    mp: base.mp,
    maxMp: base.mp,
    atk: base.atk,
    def: base.def,
    skills: [...base.skills],
    states: [],
    encyclopedia: base.encyclopedia,
  };
}

ここで重要なのは、

  • Three.js の Mesh には一切 HP を持たせていない
  • HP や攻撃力は RPG.enemy / RPG.player にのみ存在する

という分離だ。

表示とロジックを混ぜないことで、 後からUIや演出を増やしても壊れにくくなる。


ダメージ計算を関数として切り出す

まずは最低限のダメージ計算を用意する。

calcDamage(attacker, defender) {
  const base = Math.max(1, (attacker.atk || 0) - (defender.def || 0));
  const rnd = Math.floor(Math.random() * 5);
  return base + rnd;
}

ここでは、

  • 攻撃力 − 防御力
  • 最低ダメージは 1
  • 少しだけランダム要素を足す

という、非常に素朴な計算にしている。

複雑な計算は後からいくらでも足せる。 この段階では 必ず減ることが重要だ。


ダメージ適用は別関数にする

HPを減らす処理も関数に分ける。

applyDamage(targetBattler, dmg) {
  targetBattler.hp = Math.max(0, targetBattler.hp - dmg);
}

ここで 0 未満にならないようにしておくと、 後続の判定が楽になる。


プレイヤー攻撃で敵HPを減らす

playerAction() の攻撃確定タイミングで、 RPG.enemy に対してダメージを適用する。

const dmgValue = this.calcDamage(RPG.player, RPG.enemy);
this.applyDamage(RPG.enemy, dmgValue);

const dmg = this.showDamage(dmgValue, this.enemy.position);
this.animateDamage(dmg);

if (RPG.enemy.hp <= 0) {
  this.endBattle(this.json.description + ' をたおした!');
  return true;
}

ここでやっていることは3つだけ。

  1. ダメージを計算する
  2. HPを減らす
  3. HPが0ならバトルを終了する

表示用のダメージ数字は あくまで演出で、 勝敗の判定はHPだけを見る。


敵攻撃でも同じ処理を使う

enemyフェーズでも同様に、 プレイヤー側のHPを減らす。

const dmgValue = this.calcDamage(RPG.enemy, RPG.player);
this.applyDamage(RPG.player, dmgValue);

const dmg = this.showDamage(dmgValue, this.player.position);
this.animateDamage(dmg);

if (RPG.player.hp <= 0) {
  this.endBattle('あなたはたおれた…');
  return true;
}

攻撃する側とされる側が入れ替わるだけで、 処理は完全に同型だ。


バトル終了処理を endBattle() にまとめる

勝敗や逃走が決まったら、 即座にフェーズを止める。

endBattle(resultText) {
  UI_CONFIG.mBox.show(resultText, { autoCloseTime: 2 });
  this.isFinished = true;
  this.phase = 'end';
}

ここで、

  • メッセージ表示
  • isFinished
  • phase = 'end'

を同時に行うことで、 update() がそれ以上進まなくなる。


「最低限のRPG」が成立する瞬間

この章まで実装すると、バトルは次の状態になる。

  • 攻撃するとHPが減る
  • HPが0になると勝敗が決まる
  • 勝利・敗北・逃走で必ず終了する
  • 終了後はフィールドに戻れる

派手な演出やUIはまだなくても、 RPGとして破綻しない骨格は完成する。

次の章では、 敵の攻撃が「即時すぎる」問題を解消するために、 走って近づくアニメーションを追加していく。

3. 敵攻撃が早すぎ問題 → 「走ってきて殴る」を入れる

この章は、体感として一番「欲しい」改善点だ。

enemyフェーズを追加し、HPで勝敗が決まるようになったものの、 実際に遊ぶとすぐに違和感が出る。

  • 敵ターンに入った瞬間にダメージが入る
  • 攻撃の予備動作がない
  • 反応する暇がなく、不意打ち感が強い

ロジックとしては正しくても、 演出の時間がゼロだと、プレイヤー体験としては破綻する。


問題の本質は「速さ」ではなく「時間が見えない」こと

この問題は、敵の攻撃力や頻度の問題ではない。

  • ダメージが入るまでの「過程」が存在しない
  • プレイヤーが目で追える時間がない

その結果、 「敵の攻撃が異常に早い」 という印象になる。


解決策は playerAction の構造をそのまま使うこと

すでにプレイヤー側の攻撃には、 正しい解答が実装されている。

playerAction は次の流れになっている。

  1. 開始位置(start)を保存する
  2. target 方向へ lerp で前進する
  3. 到達した瞬間に攻撃を行う
  4. 少し待機する
  5. 元の位置へ戻る

敵攻撃も まったく同じ構造にすればいい。

新しい仕組みは一切いらない。 設計を信じて「対称に写す」だけだ。


enemyAction を「移動付き」に書き換える

敵の攻撃処理を、次のように実装する。

enemyAction(delta) {
  if (this.isFinished) return true;

  if (!this._enemyAttack) {
    const start = this.enemy.position.clone();
    const target = this.player.position.clone();

    this._enemyAttack = {
      t: 0,
      duration: 0.4,      // 走ってくる時間
      wait: 0,
      waitDuration: 0.3,  // 攻撃後の溜め
      start,
      target,
      finished: false,
    };
  }

  const atk = this._enemyAttack;

  if (!atk.finished) {
    atk.t += delta;
    const p = Math.min(atk.t / atk.duration, 1);

    const dir = atk.target.clone().sub(atk.start).normalize();
    const attackPos = atk.target.clone().add(dir.multiplyScalar(-1.2));
    const pos = atk.start.clone().lerp(attackPos, p);

    this.enemy.position.copy(pos);

    if (p >= 1) {
      playSe('AttackEnemy', config.sound);

      const dmgValue = this.calcDamage(RPG.enemy, RPG.player);
      this.applyDamage(RPG.player, dmgValue);

      const dmg = this.showDamage(dmgValue, this.player.position);
      this.animateDamage(dmg);

      if (RPG.player.hp <= 0) {
        this.endBattle('あなたはたおれた…');
        return true;
      }

      atk.finished = true;
    }
  } else {
    atk.wait += delta;
    if (atk.wait >= atk.waitDuration) {
      this.enemy.position.copy(atk.start);
      this._enemyAttack = null;
      return true;
    }
  }

  return false;
}

処理の流れは playerAction と完全に同型だ。

  • 移動中は false を返し続ける
  • 攻撃完了後、待機が終わったら true を返す
  • update() 側は結果だけを見てフェーズを進める

この実装で何が変わるか

この変更だけで、体感は大きく変わる。

  • 敵が「走ってくる」ので攻撃の予兆が分かる
  • ダメージが入る瞬間を目で追える
  • プレイヤー攻撃とテンポが揃う
  • バトルが一気にRPGらしくなる

ロジックは一切変えていない。 演出時間を足しただけだ。


対称構造が効く設計は、後から強い

この修正が簡単にできた理由は、 最初から playerAction を

  • start / target を持つ
  • delta ベースで進行する
  • 完了を true / false で返す

という形で作っていたからだ。

敵用に特別な設計は不要だった。 同じ構造をコピーできる設計そのものが勝ち筋だった。

次の章では、 この「待機」に関連してハマりやすい waitDuration の単位問題(delta = 秒)について整理する。

4. waitDuration が効いたり効かなかったりする理由(deltaは秒)

敵攻撃に待機(溜め)を入れた段階で、 多くの人が一度は首をかしげる挙動に出会う。

  • 待機が一瞬で終わる
  • 効いたと思ったら次は効かない
  • 数値を変えても挙動が安定しない

これは Three.js や RPG 固有の問題ではなく、 delta の単位を取り違えたことによるロジック破壊だ。


delta は「秒」である

まず大前提として、 update(delta) に渡ってくる delta は 秒 だ。

atk.wait += delta;
if (atk.wait >= atk.waitDuration) {
  ...
}

このコードは、

  • delta 秒ずつ時間を足していき
  • waitDuration 秒に達したら次へ進む

という、極めて素直な意味を持っている。


初期値が 0 でないと、ロジックが成立しない

問題が起きる典型例がこれだ。

this._enemyAttack = {
  wait: 100,
  waitDuration: 13.3,
};

この時点で、

wait >= waitDuration

は 初期化した瞬間に成立している。

結果として、

  • フレームによっては即終了
  • フレーム順の差で一度だけ通る
  • 「効いたり効かなかったり」に見える

という、非常に分かりにくい挙動になる。

これはランダムでも気まぐれでもない。 初期条件がすでに破綻しているだけだ。

正しくは必ずこうする。

wait: 0,
waitDuration: 0.3,

13.3 は「13.3秒」である

次に混乱しやすいのがこの数値だ。

waitDuration: 13.3

これは 13.3秒待つという意味になる。

  • 0.3 → 一瞬の溜め
  • 1.0 → 考えている感
  • 2.0 → 威圧的な溜め
  • 13.3 → 長い沈黙

もし意図的なら問題ない。 ボス演出や緊張感の演出として使える。

ただし、 「フレーム数っぽい感覚」で入れると、 ほぼ確実に長すぎる。


フレーム基準で考えたいなら別の書き方をする

もし「10フレーム待ちたい」のであれば、 秒ではなく フレーム数で管理する必要がある。

waitFrames: 0,
waitFramesMax: 10,
atk.waitFrames++;
if (atk.waitFrames >= atk.waitFramesMax) {
  ...
}

ただしこの方法は、 fps が変わると体感も変わる。

今回のような RPG バトルでは、 秒ベース(delta)で統一する方が安全だ。


ゲーム制作では「数値=演出」になる

ここまでの話を整理すると、

  • ロジックは合っている
  • 単位を誤ると体感が壊れる
  • 数値はそのまま演出になる

ということが分かる。

バグのように見える現象の多くは、 実際には 数値の意味を取り違えているだけだ。

waitDuration が効かないのではなく、 効きすぎているだけ。

この感覚が掴めると、 ゲーム制作は「コードを書く作業」から 時間と体感を設計する作業に変わる。

次の章では、 完成度を一段上げるために行った UI や SE の多重発火対策を整理する。

5. “気まぐれバグ”を潰す:多重表示・多重再生・状態リセット

ここまで実装すると、 「たまにおかしい」「条件次第で壊れる」挙動が目につき始める。

  • コマンドUIがチラつく
  • メッセージが何度も表示される
  • SEが連打される
  • 1戦目は平気だが、2戦目で挙動が怪しくなる

これらはロジックミスというより、 update() が毎フレーム呼ばれることを忘れた結果起きる問題だ。

完成版にするには、 「一度だけやりたい処理」を明示的に制御する必要がある。


command フェーズで show() を毎フレ呼ばない

command フェーズは数フレーム〜数百フレーム継続する。

その間に毎フレ show() を呼ぶと、 UIの再描画やイベント登録が多重に走る可能性がある。

case 'command':
  if (!this._commandShown) {
    this.commandUI.show();
    this._commandShown = true;
  }
  this.inputCommand();
  break;
  • フェーズに入った瞬間だけ show()
  • 入力中は inputCommand() のみ呼ぶ

これだけで、 UI 周りの不安定さはほぼ消える。


fade フェーズでメッセージを一度だけ出す

敵出現メッセージも同様だ。

case 'fade':
  if (!this._appearMessageShown) {
    UI_CONFIG.mBox.show(
      this.json.description + ' があらわれた',
      { autoCloseTime: 3 }
    );
    this._appearMessageShown = true;
  }
  if (this.fadeIn(delta)) {
    this.phase = 'command';
    this._appearMessageShown = false;
    this._commandShown = false;
  }
  break;

ここでは、

  • メッセージ表示は一度だけ
  • フェーズを抜けるときにフラグを戻す

という流れにしている。


escape の SE は一度しか鳴らさない

escape フェーズは update() が回り続けるため、 何もしないと SE が毎フレ鳴る。

case 'escape':
  if (!this._escaped) {
    playSe('Escape', config.sound);
    this._escaped = true;
  }
  this.isFinished = true;
  this.phase = 'end';
  break;

「一度だけ鳴らす」処理は、 ほぼ必ずフラグが必要になる。


内部フラグは initialize() で必ずリセットする

1戦目は問題なくても、 2戦目で壊れるケースは非常に多い。

原因は、 内部フラグが残ったまま次のバトルに入ることだ。

initialize() {
  this.turn = 0;
  this.messegeTime = 5000;
  this.isFinished = false;
  this.isLanding = false;
  this.phase = 'enter';

  this._escaped = false;
  this._appearMessageShown = false;
  this._commandShown = false;
}

バトルの初期化では、

  • フェーズ
  • フラグ
  • 一時的な状態

をすべて元に戻す。


「気まぐれバグ」の正体

これらの不具合は、

  • 条件分岐のミス
  • Three.js 特有の罠

ではない。

フレーム更新の世界で「一度だけ」を管理していないことが原因だ。

逆に言えば、

  • フラグで制御する
  • フェーズの入口と出口を意識する
  • 初期化を徹底する

この3点を押さえるだけで、 「動いたのに壊れる」系の問題はほぼ消える。

次の章では、 ここまでの実装をまとめて 完成版 battle.js の全体像を整理する。

6. 完成版 battle.js 全文(または差分パッチ)

ここまでで、バトル実装は「動く」段階から 壊れずに連戦できる完成状態まで到達した。

ブログとしてこの先どうコードを見せるかは、実は重要な判断になる。


コード掲載の選択肢

battle.js のように行数が多いファイルの場合、選択肢は2つある。

全文掲載

  • 検索流入に強い
  • 「そのまま動くコード」を求める読者に刺さる
  • コピペ用途に向いている

一方で、

  • 読み物としては重くなりやすい
  • 差分の意図が伝わりにくい

という欠点もある。


差分掲載

  • 前回記事との関係が分かりやすい
  • どこをどう直したかが明確
  • 実装の意図を説明しやすい

ただし、

  • 単体では動かない
  • コピペ需要は弱くなる

という側面がある。


おすすめ構成:差分 → 全文(折りたたみ)

今回のような「続編記事」の場合、 一番バランスが良いのは次の構成だ。

  1. 本文では差分と要点だけを解説
  2. 記事の最後に完成版 battle.js を全文掲載
  3. 全文は折りたたみ(details / summary)にする

こうすると、

  • 読みたい人は実装意図を追える
  • 動かしたい人は全文をコピーできる
  • 前回記事とのつながりも保てる

という三方良しになる。


差分として押さえておくべきポイント

完成版 battle.js で、 前回から実質的に変わったポイントは次の通りだ。

  • phaseenemy / escape / end を追加
  • update() に enemy フェーズの遷移を追加
  • HP 実体(RPG.player / RPG.enemy)へのダメージ適用
  • 敵攻撃を playerAction と同型の移動アニメ付きに変更
  • waitDuration を秒ベースで正しく扱う
  • _commandShown / _appearMessageShown / _escaped による多重発火防止
  • initialize() で内部状態を完全にリセット

これらがすべて入っていれば、 完成版としての条件は満たしている。


完成版コードについて

以下に掲載する battle.js は、

  • この記事で解説した内容をすべて含む
  • 単体で動作する前提の完成版
  • 今後の拡張(スキル、状態異常、UI強化)にも耐える構造

になっている。

コード量は多いが、 一つひとつはこれまで説明してきた内容の積み重ねだ。


完成版 battle.js(クリックで展開)
// ※ ここに完成版 battle.js をそのまま貼る
// (前セクションまでで作った最終版)

ここまでで「最低限のRPGバトル」は完成

この battle.js は、

  • 戦闘が始まる
  • コマンドを選べる
  • 敵も行動する
  • HPで勝敗が決まる
  • 終了後にフィールドへ戻れる

という、RPGバトルとして最低限必要な条件をすべて満たしている。

以降は、

  • スキル
  • MP消費
  • 状態異常
  • 行動順制御
  • UI強化

といった拡張の話になる。

それらは、 この構造があるからこそ安全に足せる。

ここまで作れた時点で、 Three.js を使った RPG バトル実装としては 一つの到達点に立っている。

補足:敵モデルが Group のときに material が取れない問題

Three.js で実戦的にハマりやすい罠がこれだ。

  • enemy.material.opacity = 0;
  • 何も起きない
  • エラーも出ない

理由は単純で、 敵モデルが Mesh ではなく Group の場合があるからだ。


Three.js のモデルは Mesh とは限らない

GLTF や FBX から読み込んだモデルは、 ほぼ確実にこうなっている。

Group
 ├─ Mesh
 ├─ Mesh
 └─ Mesh

この状態で、

this.enemy.material.opacity = 0;

と書いても、

  • enemy 自体は Group
  • material を持っていない

ので、何も変わらない。


正解は「配下の Mesh を全部たどる」

Group 配下の Mesh をすべて走査し、 material を集めて処理する。

getEnemyMaterials() {
  const mats = [];
  if (!this.enemy) return mats;

  this.enemy.traverse((obj) => {
    if (obj.isMesh && obj.material) {
      if (Array.isArray(obj.material)) {
        mats.push(...obj.material);
      } else {
        mats.push(obj.material);
      }
    }
  });

  return mats;
}

これで、

  • 単一 Mesh
  • 複数 Mesh
  • material 配列を持つ Mesh

すべてに対応できる。


フェード処理や復帰処理が安全になる

この関数を通して material を扱えば、

const materials = this.getEnemyMaterials();
materials.forEach((mat) => {
  mat.transparent = true;
  mat.opacity = 0;
});

のような処理が 確実に効く。

フェードイン/フェードアウト、 戦闘終了時の material 復帰なども壊れない。


初学者がつまずきやすい理由

この問題が厄介なのは、

  • エラーが出ない
  • 一部のモデルでは動く
  • 別のモデルでは動かない

という「気まぐれバグ」に見える点だ。

実際には Three.js の仕様そのもの。

「モデル=Mesh」と思い込むと必ず踏む地雷なので、 実装記事に入れておく価値は高い。


この小節が伝えている本質

ここで伝えたいのは material の話だけではない。

  • Three.js は階層構造が前提
  • 見えているものと、触っているオブジェクトは違う
  • 実戦では traverse が基本になる

この感覚を一度掴むと、 Three.js の理解が一段階進む。

battle.js にこの処理が入っている理由も、 「たまたま」ではなく 必要だったからだ。