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

BGMは交換ラボ / springin でお借りしています。
演出・アニメ[1]|効果音ラボ
和太鼓をドドンと叩く音やキラキラ輝く音など、動画の演出やアニメに使える無料効果音です。ご利用にあたってのクレジット表記は不要です。
https://soundeffect-lab.info/sound/anime/
スマホでゲームがつくれるアプリ Springin'(スプリンギン)
プログラミング初心者でもスマホで簡単に無料でゲーム制作、ゲーム開発が可能です。文字を使わず、直感的な操作だけでオリジナルゲームや動くマンガなどが作れるビジュアルプログラミングアプリ。言葉の壁を越え、世界中の誰もがクリエイターになれるプラットフォームです。プログラミングコンテスト、プログラミングワークショップなど様々な場面で活用されています。
https://www.springin.org/実装内容
- エンカウント管理
- 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
- 逃走音
最低限これだけ。
それでも、
- バトルらしさ
- テンポ
- 区切り
は十分に伝わる。
「動く」から「ゲームになる」瞬間
コード量が増えたわけでも、 新しい仕組みを入れたわけでもない。
音を入れただけで、
- 操作が楽しくなり
- 行動が意味を持ち
- 状態遷移が自然に感じられる
この瞬間に、
あ、これはもうゲームだな
という感覚がはっきり来た。
ここまで来たら、 あとは演出や数値を足していくだけになる。
💬 コメント