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

第10章:ついに照準式戦闘完成!

照準を入れよう

第8章で敵1体だけのシンプルなターン制バトルと攻撃音を導入した。
いまさらだが、実はまだ敵画像を入れていなかったので、敵画像、当たった時の打撃マーク(タイマーで消える)も実装した。
そしていよいよ……夢だった「照準システム」に再チャレンジ!

ユーザーアイコン

前ちらっと相談したけど、プレイヤーの攻撃の時、照準を出せるようにしたいんだよね。
【前提】
・敵は複数体出現する可能性がある
・戦闘シーンで敵キャラを画面上に描画する
・敵キャラには、当たり判定エリアと回避行動の移動方向(左右のみ、上下、など)、最大移動距離が複数指定されている
・当たり判定エリアごとに、当たった場合にどのパラメータが減るかの係数が設定されている
【攻撃】
・ユーザのターンになると画面に照準が現れる。照準の形・大きさは装備している武器と技によって異なる 上下左右キーで照準を合わせ、エンターで攻撃する
・敵が回避行動をとる場合、削られるスタミナが少ない方向に移動する
(最大移動距離によって、当たり判定エリアをすべて抜けてしまう場合もあれば、辺り判定(原文ママ)エリア内に残る場合もある)
・主人公の照準と敵の当たり判定エリアがかぶっていた状態なら攻撃成功となり、パラメータが減る
例) 攻撃力と防御力から計算された値が100だった場合
急所(設定値:スタミナ100%、即死率10%)→当たったらスタミナー100、10%の確率で即死
頭(設定値:スタミナ50%、正確性30%)→スタミナー50、正確性ー30
胴体(設定値:スタミナ50%)→当たったらスタミナー50
足(設定値:スタミナ10%、すばやさ30%)→当たるとスタミナー10、すばやさー30
敵の判定エリアの最初から順に処理され、どれかの判定エリアで当たればそこでダメージが決定される(急所かつ胴体だった場合、加算して減らすことはない)
・すべての判定エリアから外れた場合は、敵の回避成功となり、ノーダメージ

AIアイコン

いいね!まずは enemyList にヒットエリア(当たり判定)を追加して、それを戦闘画面に描画してみよう 💪

1. 敵の当たり判定エリア構造を定義


enemy.hitAreas = [
{
name: "急所",
x: 30, y: 20, w: 20, h: 20,
damageRate: 1.0,
extraEffect: { type: "instantKill", chance: 0.1 }
},
{
name: "頭",
x: 25, y: 10, w: 30, h: 20,
damageRate: 0.5,
extraEffect: { type: "accuracyDown", amount: 30 }
},
{
name: "胴体",
x: 20, y: 30, w: 40, h: 40,
damageRate: 0.5
},
{
name: "足",
x: 25, y: 70, w: 30, h: 20,
damageRate: 0.1,
extraEffect: { type: "speedDown", amount: 30 }
}
];

2. 照準の構造


let currentWeapon = {
range: { w: 20, h: 20 },
shape: "rectangle" // 照準の形状
};
let crosshair = { x: 100, y: 100 }; // 画面上の照準位置

3. 敵の回避行動


enemy.evasion = {
direction: "horizontal", // "horizontal" or "vertical"
maxDistance: 20
};

4. 照準と当たり判定の重なり検出


function isHit(crosshair, range, hitArea) {
return (
crosshair.x < hitArea.x + hitArea.w &&
crosshair.x + range.w > hitArea.x &&
crosshair.y < hitArea.y + hitArea.h &&
crosshair.y + range.h > hitArea.y
);
}

5. 当たり判定順に処理して、ダメージ決定


function resolveAttack(enemy, crosshair, weapon) {
// 回避行動(移動)
const shift = enemy.evasion.maxDistance;
if (enemy.evasion.direction === "horizontal") {
enemy.x += (Math.random() < 0.5 ? -1 : 1) * shift;
} else {
enemy.y += (Math.random() < 0.5 ? -1 : 1) * shift;
}

// 判定エリアの順にヒットチェック
for (let area of enemy.hitAreas) {
const movedArea = { x: enemy.x + area.x,
y: enemy.y + area.y,
w: area.w,
h: area.h
};
if (isHit(crosshair, weapon.range, movedArea)) {
return {
damage: Math.floor(baseDamage * area.damageRate),
effect: area.extraEffect || null
};
}
}

return { damage: 0, effect: null }; // 全部避けられた!
}

敵の当たり判定をデバッグ用に仮表示し、ユーザのターンで照準をカーソルで移動、エンターで攻撃できるようにした。

ChatGPTに雛形を作ってもらえるだけでありがたいのだが、戦闘シーンはそこそこ複雑なので、調整もそれなりに必要になった。

📜 ChatGPTとの共同開発心得

照準システムを入れたら、前に入れたはずのダメージ計算式が呼び出されなくなっていたので、組み込み直した。
hitAreaが敵の位置からの相対座標だったのだが、絶対座標として実装されていて「当たってるはずなのに当たらない!」なんてこともあった。
照準決定のエンターキーとメッセージウィンドウでのエンターキーとが干渉してちょっとバグったりもした。

また、敵が「ダメージの少ない方向を見極めて回避行動をとる」という風にしたかったのだが、お願いしてもうまくいかなかった。
まあ、このぐらいは自分で工夫して作ってみましょう。
回避しない場合、左に避けた場合、右に避けた場合、などのダメージを算出して比較、少ないダメージの方向に移動する(または動かない)ロジックを組み込んだ。

ヒットエリアを設定するツールを作成

当たり判定のエリア指定だが、手動でピクセル位置を割り出すのは気が狂いそうなので、ヒットエリア設定ツールを作成した。
こういうミニ便利ツールは、ChatGPTを使うと「瞬殺」である。

ユーザーアイコン

ゲーム制作用に、画像を読み込んで画面上に表示し、範囲指定して辺り判定(原文ママ)のエリアを複数指定できるツールを作りたい。
出力するテキストのイメージはこんな感じ。
ヒットエリアの数は可変で、nameはテキストBoxなどで任意に指定できる。
===
hitAreas: [
{ name: "急所", x: 30, y: 20, w: 20, h: 20, },
{ name: "頭", x: 16, y: 8, w: 16, h: 16, },
{ name: "胴体", x: 12, y: 24, w: 24, h: 24, },
{ name: "足", x: 14, y: 52, w: 20, h: 12, }
]
===

これだけでもうHTMLができあがってしまう。

ユーザーアイコン

いい感じだね! 範囲が分かりやすいように、マウスを押してドラッグしている間、赤い枠がプレビューで表示されるようにできる?

以上、たった二言で、作成完了……!

瞬く間に出来上がった当たり判定設定ツール

複数の敵に対応

お次は敵の複数化である。
• 敵が画面上に横並びになる
• どの敵を狙うかを照準で選ぶ
• ヒットエリアごとの効果反映
• 複数の敵がループで攻撃。
という処理をChatGPTに実装してもらう。
敵の間隔も適当に空けて配置してくれるので実にありがたい。

敵の体力などは数値テキストで表示する予定だったが、体力バーも出せると言うのでお願いしてみた。

ユーザーアイコン

各敵の体力バーも表示したいな。そして体力0になった敵は、画面から表示を消したい。

ChatGPTは、ソースの修正箇所を教えてくれるだけでなく、以下の提案もしてくれた。

AIアイコン

補足:もっとRPGっぽくしたいとき
• HPバーの上に 敵の名前 を表示する
• 倒れた敵に 透過画像を使ってフェードアウト演出
• 撃破時に 敵画像をちょっと震わせて消す など

「敵が震えて消える」演出は非常にありがたかったので早速導入方法を教えてもらった。
そうそう、こういう小さな気配りがゲーム体験をよくするんだよね。
わかっちゃいるけど、自力で実装すると並列処理などわけが分からなくなってきて、かなり大変な部分。
そこをサクッと組み込んでくれるのは非常にありがたい。
(サクッと、と言っても、コードを組み込んだら、敵一体倒した後フリーズしてしまったので、やっぱり調整はいる)

次は、忘れちゃいけないターン決めである。

ユーザーアイコン

今って敵のターンの中で敵がリスト順に順次攻撃となってるけど、敵の中にも素早さがいろいろあるから、プレイヤーと敵とのターンが入り混じるほうが自然だと思うけどどう?

ChatGPTは、
1)行動順リストをリスト化する(キューに入れる)
2)キューを戦闘から消化して攻撃
3)ユーザのターンと敵のターンで分けるのでなく、次のターンを呼ぶように修正
という機能をTurn Order Systemとして生成してくれた。
戦闘ループもこれに合わせて修正する必要があるので編集を依頼。

自力では途方に暮れていたであろうターン決めをすぐに実装してくれたのは非常にありがたい。
ただ、今回は照準システムを導入しているおかげで、ちょっとした問題が発生。
射程が広くて二体以上の敵に当たった場合も、一体にしかダメージが入らないので不自然だ。

そこで、敵ごとにダメージを与えていく方式に修正してもらった。
また、照準とHitAreaの中央位置を算出して、その位置を計算する関数を作ってもらい、打撃をその位置に表示するようにした。
これで、「照準が広い武器」で複数的にダメージを与えるか、「照準が狭くて強い武器」で一つの敵を集中的に攻撃するかなど、戦闘の戦略性が増した。

プレイヤーの体力も、画面全体にバー表示するようにし、一定の体力以下になったら黄色や赤などの警告色に変わるようにした。

マップのアイディアメモ

▲ 複数敵との戦闘シーン。

敵キャラのメモ

▲ 照準の形によって、複数の敵を同時に攻撃できる。

これで複数敵の戦闘シーン自体は作れたのだが、まだ問題がある。
現在の実装ではenemyListに敵の情報を記載している。
たとえばヘビを3体出したい場合、同じ敵の情報をまるまる3回コピペするのはあまりにも不毛だ。

ChatGPTに敵を複製する方法を聞いたところ、「深いコピー(ディープコピー)」でクローンすべし、とのこと。 structuredClone(enemyList["snake"])のように書けば深いコピーができるそうだ。
ただ「モダンなブラウザで使えるネイティブのディープコピー」という言い回しがあり、少々気になった。

ユーザーアイコン

「モダンなブラウザー」の範囲ってどの辺?

モダンなブラウザー
AIアイコン

🛠 代替手段:
(function deepClone(obj) {
return JSON.parse(JSON.stringify(obj)); // 限界あるけど軽めの構造ならOK
}

stringifyは、
 • 関数
 • undefined
 • Dateオブジェクト
 • 循環参照
を利用できないらしいが、現在のところどれにも該当しないので、代替手段のほうを採用することにした。

状態異常を導入しよう

いい感じにノッてきたので、次、状態異常行ってみよう!

ユーザーアイコン

毒とか眠りとかの状態異常を追加したかったんだよね。

AIアイコン

いいね、それなら状態異常の基本設計から一緒に進めようか。まずはざっくり設計案:

状態異常一覧

おおお、 すごくたくさん提案してくれたゾ。いや、簡単に実装できるのなら、どんどん入れていきたいけれども。

ユーザーアイコン

なんなら提案してくれた状態異常全部入れたいけど(笑)、少しずつ導入したほうがいいかな。

AIアイコン

(笑)さすがに一気に全部は大変だから、段階的に追加していくのがいいね。

残念、同時に導入は却下されてしまった。
とりあえず序盤の敵としてハチとヘビが出てくるので、毒を実装してもらうことにする。
ターンが進むと勝手に毒が消えてしまうゲームもあるけど、どくけしそう(仮名)を使うまでは毎ターン固定で体力が削られることにした。

まず、敵と自分のステータスに毒状態を導入し、UIとしてどくろマーク💀を表示する。
次に毒攻撃をしてくる敵に、以下のようにattackEffectsを実装。


  enemyList["snake"] = {
name: "ヘビ",
attack: 10,
accuracy: 100,
// ...
attackEffects: [
{ type: "poison", chance: 0.3 } // 30%の確率で毒
]
};

さらに、アイテム定義オブジェクトを作成し、説明文や効能を設定する。
useをメソッド風に書くのでなく、ターゲットに対しアロー関数で効果をそのまま書いてしまうやり方が、個人的には勉強になった。


  const itemDefinitions = {
"どくけしそう": {
name: "どくけしそう",
description: "毒を治す",
type: "curePoisoned",
use: (target) => {
if (target.poisoned) {
target.poisoned = false;
return "毒が治った!";
} else {
return "使ったが効果はなかった...";
}
}
},
...
}

アイテムを使う側からは、
const resultText = item.use(playerStatus);
のように呼び出して、結果をメッセージボックスに表示。アイテムをひとつ消せば実装完了だ。

ランダムエンカウント方式に変更

敵とのエンカウントについては第8章で実装していたのだが、敵をフィールド上でどう表現するかはまだ迷っていた。
フィールド上を歩いている敵を描くのも大変だし、あらゆる敵に対応するNPCとして、もやもやした霧みたいな絵も作ってみたものの、なんだかいまひとつ。

ユーザーアイコン

敵とのエンカウントの方法は、
・歩いていくと確率で出現し、戦闘に入る
・フィールド上を歩いているモンスターと接触したら戦闘に入る
のどっちにするかまだちょっと迷ってる。
マップ上でも、「この辺でこのモンスターと遭遇する確率が何パーセント」とか「シナリオがここまで進んだらこういう敵が出てくる」とか、管理する必要があるよね。

AIアイコン

エンカウント方式の選択と、出現管理の仕組みはRPG設計の肝でもあるから、以下にポイントをまとめてみたよ。

シンボルエンカウントとランダムエンカウントの比較表
ユーザーアイコン

シンボルエンカウントで途中まで作りかけてたけど、
・全部の敵キャラのミニ画像を用意するのが大変・・・
・プレイヤーが敵を避けていって、レベルを上げないまま先のマップに行ってしまう危険性がある
というのが気になってきたので、ボス以外はランダムエンカウントにしようかな。

AIアイコン

その判断、すごく理にかなってると思う!✨ シンボルエンカウントは視覚的に楽しいけど、素材コストやバランス調整の難しさがあるから、以下みたいに役割を分けるのは超おすすめ。

雑魚敵とボスの表

肝心のランダムエンカウントの実装方法だが、ChatGPTはencounterZoneを作って、歩くごとに確率で敵が現れるやり方を提案してくれた。
が、マップが複数あり、マップとゾーンが1:1対応にならないので、以下のように入れ子構造で定義することになってしまう。


  const encounterZones = {
"myMap": [
{
name: "forest",
area: { x: 0, y: 0, w: 10, h: 10 },
enemies: [
{ id: "bee", rate: 60 },
{ id: "snake", rate: 40 }
]
},
{
name: "mountain",
area: { x: 11, y: 0, w: 10, h: 10 },
enemies: [
{ id: "bat", rate: 50 },
{ id: "goblin", rate: 50 }
]
}
]
};

敵の「当たり判定」と同じく、一つのマップ上に多数のエリア定義が必要になるということだ。
個人で作っている初のRPG企画で、そこまで凝った作りにするのがいい加減めんどうになってきたので、背景のマップチップに応じてzone判定するように自前で書き換えた。
さらに、戦闘勝利時のレベルアップ判定も追加した。
また、照準のサイズを「武器」ごとに設定できるように改修した。

ちなみに、敵に戦って勝利した後、次に敵に遭遇するといきなり勝利に飛んでしまうバグが発生。
よくよく確認したところ、第2章で導入した「psgweb」で戦闘勝利音を流していたのだが、音楽が流れ終わった後にコールバックで勝利のイベントに飛んでいるせいだった。
もう二度と間違いを犯したくないと言う決意を込めて、
「psg.setOnEndedを使う時は、必ずセットでnullにするようにする。」
と宣言したら、とくに「!」などをつけたわけでもないのに、ChatGPTがこりゃ大事だと思ったらしく記憶(メモリ)に書き込みに行っていたのが面白かった。
こういうところは、実に空気の読める奴なのである。

ひとまず夢だった照準システムが実装できて大満足……!

次回は、モンスター画像やキャラクター歩行画像の作成について。
はたして、ChatGPTだけで、ドット絵も作れるのか?
絵がへたくそなプログラマー/シナリオライターにもゲームを作る術はあるのか?
試行錯誤の日々をお伝えするよ!