はじめに
数日前にバトル実装として、「バトルに入る」「敵出現」「コマンドUI」「プレイヤー行動」までやりましたが、 今回は、そこに「敵ターン」「HP決着」「テンポ調整」「安全なフェーズ制御」を足して、遊べる最小構成まで仕上げたので、その実装メモです。
[JavaScript] Three.jsでRPGバトルをゼロから組む|フェーズ管理・UI・アニメーション・SEまで
three.jsを使ってRPGのバトルシステムをゼロから実装。エンカウント管理、フェーズ駆動のBattleクラス設計、敵出現演出、コマンドUI、プレイヤー行動アニメーション、効果音・BGM制御までを段階的に解説する。
https://humanxai.info/posts/javascript-threejs-rpg-battle-phase-system/動画(パソコン)
概要
前回の記事では、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;
}
}
ここでやっていることは非常に単純だ。
actionがtrueを返したらenemyへenemyがtrueを返したら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つだけ。
- ダメージを計算する
- HPを減らす
- 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';
}
ここで、
- メッセージ表示
isFinishedphase = 'end'
を同時に行うことで、 update() がそれ以上進まなくなる。
「最低限のRPG」が成立する瞬間
この章まで実装すると、バトルは次の状態になる。
- 攻撃するとHPが減る
- HPが0になると勝敗が決まる
- 勝利・敗北・逃走で必ず終了する
- 終了後はフィールドに戻れる
派手な演出やUIはまだなくても、 RPGとして破綻しない骨格は完成する。
次の章では、 敵の攻撃が「即時すぎる」問題を解消するために、 走って近づくアニメーションを追加していく。
3. 敵攻撃が早すぎ問題 → 「走ってきて殴る」を入れる
この章は、体感として一番「欲しい」改善点だ。
enemyフェーズを追加し、HPで勝敗が決まるようになったものの、 実際に遊ぶとすぐに違和感が出る。
- 敵ターンに入った瞬間にダメージが入る
- 攻撃の予備動作がない
- 反応する暇がなく、不意打ち感が強い
ロジックとしては正しくても、 演出の時間がゼロだと、プレイヤー体験としては破綻する。
問題の本質は「速さ」ではなく「時間が見えない」こと
この問題は、敵の攻撃力や頻度の問題ではない。
- ダメージが入るまでの「過程」が存在しない
- プレイヤーが目で追える時間がない
その結果、 「敵の攻撃が異常に早い」 という印象になる。
解決策は playerAction の構造をそのまま使うこと
すでにプレイヤー側の攻撃には、 正しい解答が実装されている。
playerAction は次の流れになっている。
- 開始位置(start)を保存する
- target 方向へ lerp で前進する
- 到達した瞬間に攻撃を行う
- 少し待機する
- 元の位置へ戻る
敵攻撃も まったく同じ構造にすればいい。
新しい仕組みは一切いらない。 設計を信じて「対称に写す」だけだ。
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つある。
全文掲載
- 検索流入に強い
- 「そのまま動くコード」を求める読者に刺さる
- コピペ用途に向いている
一方で、
- 読み物としては重くなりやすい
- 差分の意図が伝わりにくい
という欠点もある。
差分掲載
- 前回記事との関係が分かりやすい
- どこをどう直したかが明確
- 実装の意図を説明しやすい
ただし、
- 単体では動かない
- コピペ需要は弱くなる
という側面がある。
おすすめ構成:差分 → 全文(折りたたみ)
今回のような「続編記事」の場合、 一番バランスが良いのは次の構成だ。
- 本文では差分と要点だけを解説
- 記事の最後に完成版 battle.js を全文掲載
- 全文は折りたたみ(details / summary)にする
こうすると、
- 読みたい人は実装意図を追える
- 動かしたい人は全文をコピーできる
- 前回記事とのつながりも保てる
という三方良しになる。
差分として押さえておくべきポイント
完成版 battle.js で、 前回から実質的に変わったポイントは次の通りだ。
phaseにenemy / 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 にこの処理が入っている理由も、 「たまたま」ではなく 必要だったからだ。
💬 コメント