ベーマガ世代、ChatGPTとゼロからRPGを作る旅

第8章:戦闘シーンを作ろう

戦闘シーンを作ろう

第1章で作った簡易プログラムでは、不完全ながら照準システムで敵を倒すプログラムを組んでいた。
が、第3章での全面作りなおしによりゼロクリアされてしまったので、もう一度戦闘シーンを作りなおさないとならない。

ユーザーアイコン

戦闘シーンを作っていきたいんだけど、どうしたらいいかな

AIアイコン

Rpg Screen Template にコメントできませんでした

canvasのソースが長くなってきたせいか、ChatGPTが上手く書き込めなくなってきたようだ。
といっても、内容のほうはバッチリ理解してくれた。

AIアイコン

OK、キャンバスを読み込ませていただきました。
戦闘シーンの実装を進めるにあたって、以下のように構成していくのがよさそうです:

戦闘シーン実装方法

AIアイコン

🔧 次にやること
まずは以下を実装してみます:
• 戦闘画面の描画(簡易UI)
• プレイヤーターンの開始(「たたかう」コマンド)
• 敵を1体表示し、HPを持たせる
よろしければ、これをベースに書き始めますね!

もともと目指していた照準システムを頼もうかどうか迷ったが、スタンダードなコマンド式ターン制バトルならこれに従うだけで作れそうだ。
とりあえず仕上げることを優先して進めてみよう。

ユーザーアイコン

やってみて~

AIアイコン

Edited Rpg Screen Template
戦闘シーン(Battle Scene)の雛形を実装しました!💥
スペースキーで攻撃 → 敵のHPが減る → 敵のターン → プレイヤーにメッセージ、という流れです。敵のHPが0になると戦闘終了し、フィールド画面に戻ります。

今回は無事にcanvasがいじれたようで、画面切り替えできるようになった。

ユーザーアイコン

今敵の体力だけ見えているけど、プレイヤーのスタミナやMPも表示できる?

ChatGPTは、ステータスも新たに実装しようとしていたので、既存のユーザステータスを利用するようにお願い。
するとこんなソースが出てきた。


  // プレイヤーのステータス表示(右側)
ctx.fillText("🧍‍♂️ レオン", 600, 70);
ctx.fillText(`💖 HP: ${playerStatus.stamina}/${playerMaxStatus.stamina}`, 600, 100);
ctx.fillText(`🔷 MP: ${playerStatus.magic}/${playerMaxStatus.magic}`, 600, 130);

……レオンって誰?
プレイヤーの名前が未定義だったから仮に入れてくれたんだろうけど、chapter1で試した仮実装でも「レオン」だったよなあ。
2度目となると、これはもう確信犯としか思えない。

ユーザーアイコン

ちなみに、レオンっていう名前の由来は?

AIアイコン

「レオン(Leon)」って名前、いくつかの意味や由来があって、RPGの主人公にぴったりの響きだよ:

レオンの名前の由来

ライオンの由来……FF2、バイオハザード、キングダムハーツにもこの名前や類似名がある、と……
なるほど、けっこうちゃんとした理由付けがあったのね。
もしかして「RPG用テンプレート」みたいなプログラムのヒントを内部で持っているのかなぁ。

ランダムに選ばれるのか、レトロRPGを作成するとこの名前が選ばれるのか。
ChatGPTとRPGを作ってみている方がいたら、ぜひ聞いてみたいところだ。

さて、この戦闘シーン、まだまだ調整が必要だ。
タイマーで文字表示が勝手にどんどん切り替わってしまうけど、ユーザがちゃんと読み終わって、エンターキー入力を待ってから次に進むようにしたい。
攻撃もスペースというのは分かりにくいのでエンターキーに統一したい。
メッセージウィンドウも新たに実装してしまったようなので、イベント用のメッセージウィンドウに統合したい。

ユーザーアイコン

今のバトルメッセージを、さっき作ったMessageWindowを使って、画面下部に表示できないかな。 ターンが変わる時も、タイムアウトでなくて、ユーザのEnterを待って切り替わるようにしたい。

canvasへの書き換えでまたエラーになってしまったようだ。
対話履歴の中に直接出力されたコードを確認したところ、やはり既存のmessageWindowを使ってくれていない。
こちら、もう一度お願いして組み込んでもらうことには成功したが、今度はターンが先に進まないことが発覚。
イベントシステムと同様、battleStepを再帰的に呼び出すようにして、解消した。

攻撃音を入れよう

ユーザーアイコン

前にタイプ音を作ってくれたけど、攻撃する音やされる音もoscで表現できる?

AIアイコン

うん、できるよ!
Web Audio API(AudioContext + OscillatorNode)を使えば、攻撃音や被ダメージ音もピコピコ系で自由に作れるよ🔊

ChatGPTは、攻撃音(短くて高めの音)、被ダメージ音(ちょっと低くて不快な感じ)、回復音(優しい感じ)を作ってくれたけど、どれもただの「短音」という感じで、戦闘に使うイメージが湧かない。

ユーザーアイコン

ノイズ入ったような音って出せる?

AIアイコン

おっ、ノイズっぽい音ね!
できます!Web Audio API で ホワイトノイズ(ザーッという音) や ランダムなパーカッション風の音 を出せるよ。

これはまさしくホワイトノイズ。


ちょっと歯が浮くかもしれないので注意。

AIアイコン

さらに応用すると…
• ローパスフィルターをかけて「ボフッ」みたいな鈍い音にできる
• ノイズ + 短い正弦波で「バシュッ」とか「ザシュッ」も再現できる

ユーザーアイコン

いいね、ザシュッって音聞いてみたい

ということで、作ってもらったのだが……
うーん、これは「ザシュッ!」なのか?
調整しているうちにノックっぽい音や紙鉄砲っぽい音ができあがっていったのだが、どうも斬撃とか打撃には聞こえない。
最後の紙鉄砲っぽい音は、武器として鉄砲が出てくるなら使えるかもしれないが、中世ファンタジー風RPGだしなあ……

「ゼロから作る」をモットーとしてきたが、さすがにこの領域(SE合成)を極めるのは厳しそう。
ファミコン時代のサウンドエンジニアってすごいなあ、と、あらためて尊敬する。

結局、今回の攻撃効果音についてはネット上の洗練されたフリー音素材を使わせていただくことにした。

そんなわけで、mp3を鳴らす機能を実装。
mp3を鳴らすのに必要なaudioオブジェクトでは、音が鳴り終わると"ended"イベントが呼び出されるらしい。
ChatGPTが提案してくれた処理はこんな感じ。


    function playAttackSoundAndProceed(callback) {
const audio = new Audio("sounds/slash.mp3");
audio.play();
audio.addEventListener("ended", () => {
callback(); // 音が終わったら次の処理
});
}
playAttackSoundAndProceed(() => {
battleEnemy.hp -= 10;
messageWindow.setText(["10ダメージ!"], () => {
battleStep("enemy-turn");
});
});

しかし、このソースを見てちょっと気になった点がある。
これだと、音を鳴らすたびにaudioオブジェクトをインスタンス化しているため、処理が重くなるのではないか。
MessageWindow同様、インスタンスを使いまわししたほうがいいのではなかろうか。

ユーザーアイコン

毎回newするとメモリ使いそうだから、外で読み込んでおいたらいい?

AIアイコン

その考え、バッチリ正解!✨
毎回 new Audio() するより、あらかじめ読み込んでおいたほうが効率的で音ズレも減るし、メモリにも優しいよ。

以下が改良版のコード。
まず、サウンドを最初に読み込んでおく。


    const sounds = {
slash: new Audio("sounds/slash.mp3"),
hit: new Audio("sounds/hit.mp3")
};

クローンで再生(クローンすれば多重に鳴らせるはず。今回は音かぶせしないけど)


    function playSound(name, callback) {
const original = sounds[name];
if (!original) return;

const clone = original.cloneNode(); // 複製して独立再生
clone.play();
if (callback) {
clone.addEventListener("ended", callback);
}
}

呼び出し側はこんな感じ。音が鳴り終わったらメッセージを表示する。


  playSound("slash", () => {
battleEnemy.hp -= 10;
messageWindow.setText(["敵に10ダメージ!"], () => {
battleStep("enemy-turn");
});
});

この後、プレイヤーのターンで、戦う以外に「逃げる」「道具を使う→回復処理する」などの処理を追加していった。
回復アイテムを使う際には、フィールドでも使っていたmenuWindowを使ってアイテムを選択できるようにした。
もともとフィールド画面で使う前提でアイテムメニューを作っていたせいで、アイテムウィンドウが出ている瞬間、フィールド画面が出てしまう、というようなバグが発生。
描画の調整が必要になった。

エンカウント、逃げた後の処理、考えること山積み!

ユーザーアイコン

NPCListみたいに敵のリストを作って、フィールド上でうろうろ歩いてて、バトル突入、みたいな形にできるかな?

ChatGPTは、① 敵リストを作る ② 敵を描画する ③ 敵をランダム移動させる ④ 接触したらバトルへ のコードサンプルを出力してくれた。
組み込もうか? と言ってくれたが、そろそろcanvasで部分的にもらったコードのどこが変わったのか確認しながら組み込む、という処理が逆に面倒になってきたので、自分で組み込み実施。

以下のような問題が発生したので修正した。
1️⃣戦闘中にもう一度敵とエンカウントして、また戦闘が始まってしまう→戦闘中はエンカウント処理ストップ
2️⃣敵のほうから自分にぶつかってきた場合に戦闘シーンに突入しない→敵の移動処理の後にも衝突判定処理を入れる
3️⃣敵の移動がスクロールでなく一マスずつ動いていた→プレイヤー同様なめらかな移動に修正
4️⃣「にげる」で逃げたのにすぐにまだ戦闘が始まってしまう
4つ目については知見がないので、いくつかChatGPTからアドバイスをもらった。

AIアイコン

🅰️ :プレイヤー or 敵の位置をずらす
🅱️ :無敵タイマーを入れる(一定時間衝突判定を無効に)

Aの方式だと、ずらす時に壁にめり込まないようにするなど考慮事項が増える。
今回は、Bの方法で3秒間の無敵タイマー(cooldown)を入れることで解消した。

まず、バトルタイムをグローバル変数で定義しておく。


    let lastBattleTime = 0;
    

逃げ出した時の時間を記録する。


messageWindow.setText([`あなたは逃げ出した!`], () => {
lastBattleTime = performance.now();
psg.stop();
psg.init(() => {
psg.play(...(psgtracks[bgm] || []), 0);
});
switchScreen("field-screen");
});

エンカウントする時、クールダウン時間を過ぎていない場合はスキップするように修正。


  for (const enemy of enemyList) {
const now = performance.now();
const cooldown = 3000;
if (
enemy.map === currentMapName &&
enemy.x === playerX &&
enemy.y === playerY &&
now - lastBattleTime > cooldown
) {
lastBattleTime = now;
console.log(`${enemy.name}がプレイヤーに接触!戦闘開始`);
switchScreen("battle-screen");
return;
}
}

で、ここからも調整事項がいろいろ。

1️⃣逃げて10秒後に敵が復活したとたんにプレイヤーと重なり、気づく間もなくバトル突入
→敵側にもタイマーをつけて、復活後3秒間はエンカウントしないようにした。
2️⃣戦闘から逃げ出した時、ときどきプレイヤーが左や右に歩き続けるバグ
 →戦闘モードに切り替える前に押していたキーが残っていたため。
 Object.keys(keyState).forEach(k => keyState[k] = false);
  のようにキー情報をクリアすることで解消。
3️⃣敵が固定されていたので、エンカウントした敵とバトルするように修正

3つめの「エンカウントした敵とバトル」は当たり前すぎると思われるだろうが、自分で作ってみると考えることは山積みである。
今の実装方針だと、敵はenemyListで定義されているのだが、
・フィールド中のスポーン地点をenemyListですべて定義しておく必要がある。
 つまり、敵の種類が3種類でも、30体いたら30体分定義しないとならない。
・倒した後の復活タイミングをどうするか。どう組み込むか……?
・複数体の敵と戦わせたい場合、シンボルとなる敵はどう表現するのか? などなど……
enemyListからクローンしてフィールド上に敵をスポーンさせるほうがいいかもしれない。
まあ、後日ゆっくり考えることにして、いったんターン決めの処理にうつる。

ターン決めとダメージ計算(簡易版)

ユーザーアイコン

ターンについて、素早さとランダム数値で決めるといいと思うんだけど、どう?

AIアイコン

これはまさに王道のRPGスタイル!


  function getInitiative(entity) {
return entity.speed + Math.random() * 5; // 素早さ+0〜5の乱数
}

    const playerInit = getInitiative(playerStatus);
const enemyInit = getInitiative(battleEnemy);

if (playerInit >= enemyInit) {
battleStep("player-turn");
} else {
battleStep("enemy-turn");
}

ほ~、ランダム値が足し算となると、レベル差が大きい時には、レベルの高いほうが確実に先攻する方式になりそうだな。
まあ、調整はいずれ考えるとして、次に進もう。

ユーザーアイコン

ダメージについても、attackとdefenseとランダムで計算式があるといいよね。

AIアイコン

いい感じに実装進んでますね!
さて、攻撃時のダメージ計算式についてですが、基本的には以下のような構成にすると、RPGらしいダメージ計算になります:


    function calculateDamage(attacker, defender) {
const baseDamage = attacker.attack - defender.defense / 2;
const randomFactor = Math.random() * 4 - 2; // ±2のランダム補正
const totalDamage = Math.max(0, Math.floor(baseDamage + randomFactor));
return totalDamage;
}

へー、ターン決めとぜんぜん式が違うな。防御力は割り算してるぞ。
ランダム補正値はこんなに小さくていいものなのかどうか?
攻撃力と防御力は、レベルアップごとにどのぐらいの速度で足していったらよいんだろう。
……っと、それはテストしながら考えればいいか。
そういえば、装備の中身もまだ実装してなかったし。

装備機能の実装

前回、簡単なターン制バトルを作ってきた。
RPGにおける攻撃力や防御力は、装備で盛るのが一般的なので、今回はそこを実装してみる。

ユーザーアイコン

装備機能がまったくできていないのが 気になってるんだよね。

AIアイコン

おお、いい着眼点!戦闘システムが整ってきた今、装備機能を入れると一気に“RPGらしさ”が高まります!

①プレイヤーに装備スロットを用意する


  playerStatus.equipment = {
weapon: null,
armor: null,
accessory: null
};

②装備アイテムに補正データを設定する


  const equipmentData = {
"木の剣": { attack: 5 },
"鉄の剣": { attack: 10 },
"革のよろい": { defense: 3 },
"鉄のよろい": { defense: 6 },
"すばやさの指輪": { speed: 2 }
};

③装備補正を加味した結果を返す関数を準備する


  function getEffectiveStatus(param) {
let base = playerStatus[param];
let bonus = 0;

for (const slot in playerStatus.equipment) {
const item = playerStatus.equipment[slot];
if (item && equipmentData[item] && equipmentData[item][param]) {
bonus += equipmentData[item][param];
}
}
return base + bonus;
}

④ダメージ計算にて、上記のgetEffectiveStatusを呼び出す。


  function calculateDamage(attacker, defender) {
const attackerAtk = getEffectiveStatus.call(attacker === playerStatus ? playerStatus : battleEnemy, "attack");
const defenderDef = getEffectiveStatus.call(defender === playerStatus ? playerStatus : battleEnemy, "defense");
const baseDamage = attackerAtk - defenderDef / 2;
const randomFactor = Math.random() * 4 - 2;
const totalDamage = Math.max(0, Math.floor(baseDamage + randomFactor));
return totalDamage;v }

ソースが長くてcanvasにコピペできなくなってきたので分割し、相談を進める。
メニューアイテム側にも装備メニューを実装し、体の各部位にアイテムを装備できるようにする。
さらに、アイテム一覧画面でも(装備中)と分かるようにUIを変更した。

次回はフラグ管理とショップでの売買スクリプト実装。
それと、だんだん規模が大きくなって管理が辛くなってきたので、プログラムの構造整理や開発環境を整えた話についても紹介するよ!