ベーマガ世代、ChatGPTとゼロからRPGを作る旅
第16章:取得アイテム管理、劣化するアイテム、状態異常、中ボス戦
取得アイテムの管理
RPGを作り進めていくと、状態を管理すべきものがたくさんあることに気づく。
今回手を入れるのは、地面に落ちているアイテム類。
今のままだと、ロードのたびに取得アイテムが復活して無限にアイテムが取得できてしまう。
かといって、アイテム類をすべてセーブデータに入れるのは、重くなりそうだ。
ChatGPTと相談し、アイテムに一意のIDをつけて、取得したフラグだけ持たせるようにした。
1️⃣テンプレート:一意のアイテムIDとマップ上の配置情報。
2️⃣アイテム定義:アイテム説明や使用効果をアイテム名ごとに管理。
3️⃣取得アイテムリスト:セーブ用に、アイテムを取得したかどうか管理。
4️⃣フィールド上のアイテム:現在マップ上に残っているアイテム
ゲーム開始時やロード時に、1️⃣テンプレート と3️⃣アイテム定義 から4️⃣フィールド上のアイテム に転記する。
こうすれば、後からマップを拡張しても、セーブデータがそのまま使用できる。
「特定のイベント発火で配置されるアイテム」というのも作りかけたが、バグ管理が大変。
ちょっと強引だが、結局、イベントオブジェクトで代用することにした。
(イベントオブジェクトに乗るとスクリプトが発動して、アイテムが手に入る)
また、アイテムやお金には、上限を設定することにした。
無限に集められると、ゲームバランスも崩れるし、いずれは表示幅からあふれてしまう。
しかし、同時に別の考慮事項も爆増した。
たとえば上限10個で、今8個持っていて、3個落ちていたらどうするか。
「1個フィールドに残す」という実装が大変すぎるので、「2個拾って1個は消える」「3個とも拾えない」の2択で悩む。
お金が上限を超える場合、売却時にも「お金が持ちきれないので売れない」などのメッセージがいる。
さらに、後述する劣化アイテムでも考慮が必要になった。
劣化するアイテム
このゲームの世界では、食べ物は劣化したり腐ったりする。
腐った食べ物なんて捨てるしかないだろうって?
いや、実はこのゲーム、そもそも「捨てる」機能がない!(一度作ったが消した)
理由は以下の通り。
理由1:重要アイテムを捨ててしまったトラウマのため
(たしかドラクエ2で「じゃしんのぞう」を捨ててクリアできなくなったような……)
理由2:捨てられない「大事なもの」枠を作るのが面倒なのと、別枠そのものに違和感があるため
理由3:その辺にゴミを投棄していくのは、サステナブルじゃない! ため
町中で「剣を投げ捨てた」って……なんか物騒だし!
理由4:このゲーム、「捨てられたもの」の痛みも根底のテーマにあるので……
腐ったアイテムをずっと持ち歩くんかい! と怒られそうだが、ちゃんと使い道もあるのでご安心を。
このアイディアを、ChatGPTに話したら、
それ最高のプレイヤー体験です…!🥚→💀→💡✨
「え、腐ったアイテム…捨てるしかないじゃん」
→「えっ、●●にできる!?」「しかも●●!?強い!!」
という 負からの逆転発想は、まさに名RPGの仕掛けですね…!
💡 豆知識:こういう設計、ゲーム用語で“バッドグッド”と呼ばれたりもします
→ 一見マイナスに思える要素が、別の角度から見ると有効だったり便利だったりするやつ!
と、豆知識を教えてもらいました。
バッドグッド……そんなゲーム用語があるとは……
ぐぐっても出てこなかったけど、これ本当ですか? 教えてください、ゲーム業界の皆さん!
さて、実装方法。以下のように、まず劣化用テーブルを作成する。
const DECAY_TABLE = {
"しんせんな卵": { chain: ["しんせんな卵", "ふつうの卵", "腐った卵"], intervalMs: DAY_MS },
"しぼりたての牛乳": { chain: ["しぼりたての牛乳", "ふつうの牛乳", "腐った牛乳"], intervalMs: DAY_MS },
"焼きたてのパン": { chain: ["焼きたてのパン", "ふつうのパン", "カチカチのパン"], intervalMs: DAY_MS },
};
const DECAY_INDEX = (() => {
const map = {};
for (const key in DECAY_TABLE) {
const def = DECAY_TABLE[key];
def.chain.forEach(name => map[name] = def);
}
return map;
})();
なんだかややこしく見えるが、ここでは単に一行を
"しんせんな卵": { chain: ["しんせんな卵", "ふつうの卵", "腐った卵"], intervalMs: DAY_MS },
"ふつうの卵": { chain: ["しんせんな卵", "ふつうの卵", "腐った卵"], intervalMs: DAY_MS },
"腐った卵": { chain: ["しんせんな卵", "ふつうの卵", "腐った卵"], intervalMs: DAY_MS },
のように複製しているだけのようだ。
プレイヤーステータスに、劣化アイテム取得日時を保存するための
itemLog: {},
を追加しておく。
アイテムログ内に劣化アイテムがあったら劣化処理に渡すループを作成。
function processDecay(now = Date.now()) {
const seen = new Set();
for (const stageName in playerStatus.itemLog) {
const def = DECAY_INDEX[stageName];
if (!def) continue; // 劣化非対象
if (seen.has(def)) continue; // 同じチェーンを重複処理しない
processDecayChain(def, now);
seen.add(def);
}
}
劣化処理そのものは、ちょっとややこしい。
function processDecayChain(def, now) {
const { chain, intervalMs } = def;
for (let stageIdx = 0; stageIdx < chain.length - 1; stageIdx++) {
const fromName = chain[stageIdx];
const toName = chain[stageIdx + 1];
const lots = playerStatus.itemLog[fromName];
if (!lots || lots.length === 0) continue;
// ロット配列を前から順に処理(古いもの優先)
let newLots = [];
for (const lot of lots) {
let { quantity: q, ts } = lot;
if (q <= 0) continue;
// このロットがこの段階に来てからの経過時間
let age = now - ts;
// 何段階進めるか(2段階以上進むこともある)
while (q > 0 && age >= intervalMs && stageIdx &;t; chain.length - 1) {
// 次段階へ移せる最大数(在庫上限チェック)
const moved = addWithLot(toName, q, ts + intervalMs);
if (moved &;t;= 0) {
// 受け入れ先が満杯 → これ以上進めないので現段階に残す
break;
}
q -= moved; // 現段階から減る
ts += intervalMs; // 段階が1つ進んだぶん時刻も進める(残り時間を正しく引き継ぐ)
age = now - ts; // 進んだ段階での新しい age
// さらに interval を超えていれば、この while で連続で進んでいく
// ※ ただし次段階の上限で詰まったらそこで止まる
}
if (q > 0) {
// まだ残っている分は現段階に残す(ts は据え置き)
newLots.push({ quantity: q, ts });
}
}
// 残すロットで置き換え(0 個ならキーごと消す)
if (newLots.length > 0) {
playerStatus.itemLog[fromName] = newLots;
} else {
delete playerStatus.itemLog[fromName];
}
// items 側の在庫数をここで調整
const remain = newLots.reduce((a, b) => a + b.quantity, 0);
if (remain > 0) {
playerStatus.items[fromName] = remain;
} else {
delete playerStatus.items[fromName];
}
}
}
これだけ複雑になっているのは、元々のアイテムテーブルもいじらなければならないせい。
劣化先アイテム数が「上限」に達していると劣化が進まない仕組みになっている。(裏技? 仕様バグ?)
いったん、実時間24時間で第一段階劣化、次の24時間で第二段階劣化することにした。
回復用の食物をたくさん買い込んでも、日にちがたつと腐ってしまうので要注意!
なお、実時間だけだとテストも大変なので、セーブ/ロード時に時計が3時間進むようにしている。
早く腐らせたい場合は、8回セーブ/ロードを繰り返せば一段階劣化する。
状態異常アイテム
戦闘での状態異常について、毒状態については実装済みである。
が、これだけではさすがに芸がないので、アイテムで固い敵の防御力を下げるアイテムを作ることにした。
プレイヤー側には、chatGPTが毒状態を作った時に仮入れした(=まだ使っていない)以下のソースが眠っていた。
if (type === "defdown") playerStatus.defense = Math.floor(playerStatus.defense * 0.8);
しかし、これだけだとステータスが下がったまま戦闘が終わっても戻らない。
そこで、戦闘開始時に防御力をバックアップしておき、一定ターン経過すると戻るようにした。
戦闘ターンごとに以下のアップデート関数を呼び出し、ターンを減らしていく。
function updateStatusEffects(entity) {
if(debug) console.log("updateStatusEffects");
if (!entity.statusEffects) return;
const expiredMessages = [];
for (const type in entity.statusEffects) {
const effect = entity.statusEffects[type];
if (effect.turns !== Infinity) {
effect.turns--;
if (effect.turns <= 0) {
delete entity.statusEffects[type];
if (type === "defDown") {
entity.defense = entity.baseDefense;
expiredMessages.push(`${entity.name || playerName}の防御力が元に戻った!`);
・・・
}else if (type === "evasionDown") {
entity.evasion = entity.baseEvasion;
expiredMessages.push(`${entity.name || playerName}は攻撃を回避できるようになった!`);
}
}
}
}
return expiredMessages;
}
ChatGPTが毒状態と別にいろいろ提案してくれるので、なるべく共通化すべく、
これ、ここにまとめて!
これ、ここに移すよね?
と確認しながら実装を進めた。
羽根や足などを攻撃するとスピードダウン、という既存機能についても、状態異常系にまとめることにした。
(最終的に、スピードダウンでなくて、回避率ダウンにした)
中ボス戦
さて、いよいよ中ボス……エン=ソウルの実装である。
怨念の塊のようなコイツのビジュアルは、目玉がたくさんあるおどろおどろしいものにした。
盛り上がるように、ボス専用の曲も準備してある。
目玉がいっぱいのボスを眺めて、
「なんだかこれ、ゴーファーの野望っぽいなあ……」
などなど往年のグラディウスシリーズを思い出して懐かしんでいるうちに、
外周の目を攻撃 → 弱点の目玉露出
という流れを作ってみたくなってきた。
本当は「すべての目を破壊したら真眼(コア)が開く!」みたいな演出が理想だが、こちとらシューティングゲームじゃないので、簡易版でも十分だ。
ひとつひとつの目のアニメーション作るの地獄だし。
ChatGPTが考えた案はこんな感じ。まず敵の設定。
enemyList["ensoul"] = {
name: "エン=ソウル", drawX: 0, drawY: 120, bigImage: "ensoul_stage1", // 最初は閉じ目画像
exp: 100, gold: 300, attack: 20, defense: 20, speed: 5, accuracy: 90, maxStamina: 200, stamina: 200,
aiPhase: 1, // 1: 全部閉じてる, 2: 外周目が開く, 3:
弱点露出 aiTurnCounter: 0,
behavior: { waitChance: 1.0, attackChance: 0.0 },
hitAreas: [ /* 必要に応じて調整 */ ], evationRange: { ifEvation: false, distanceX: 0, distanceY: 0 }
}
そしてなんと、battleStepで敵のターンの際に呼び出す専用関数を用意する提案。
function updateBossPhase(enemy) {
if (enemy.name.startsWith("エン=ソウル")) {
enemy.aiTurnCounter = (enemy.aiTurnCounter || 0) + 1;
if (enemy.aiPhase === 1 && enemy.aiTurnCounter >= 2) {
// 外周の目が開く
enemy.aiPhase = 2;
enemy.bigImage = "ensoul_stage2";
// 開いた目画像に切り替え
enemy.behavior = { waitChance: 0.3, attackChance: 0.7 };
messageWindow.setText(["エン=ソウルの周囲の目が開いた……!"]);
}
if (enemy.aiPhase === 2 && enemy.aiTurnCounter >= 4)
{
// 中央の目が開いて攻撃開始
enemy.aiPhase = 3;
enemy.bigImage = "ensoul_stage3";
// 全目開いた状態
enemy.behavior = { waitChance: 0.1, attackChance: 0.9 };
messageWindow.setText(["中央の目が開き、怨念が解き放たれる!"]);
}
}
}
じーっと目を眺めていると、それぞれに表情がある気がしてきたので、当たり判定に「あざ笑う瞳」「悲しい瞳」などなど、それっぽい名前をつけることにした。
目ごとにセリフが出てくると面白いのではないかと思い、ChatGPTに相談してみた。
眼が空いた(原文ママ)時にマヒとか呪いとか確率で状態異常が起きるとよさそうだね。 あと、外周の当たった眼によってメッセージが変わるのも面白いかも。
これをどう解釈したものか、帰ってきたこんな設定案がこんな感じ。
hitAreas: [
{ name: "真眼", x: 192, y: 163, w: 98, h: 82 ,damageRate: 1 }, // 中心、弱点
{ name: "見通す瞳", x: 181, y: 111, w: 43, h: 44, damageRate: 0.1,
statusEffect: { type: "paralyze", chance: 0.3 }, message: "身体が痺れる視線だ…!" },
{ name: "ゆううつな瞳", x: 328, y: 166, w: 48, h: 57, damageRate: 0.1,
statusEffect: { type: "sleep", chance: 0.2 }, message: "まぶたが重くなる…" },
{ name: "呆れる瞳", x: 244, y: 261, w: 52, h: 46, damageRate: 0.1,
statusEffect: { type: "defdown", chance: 0.3 }, message: "なぜか心がざわつく…" },
{ name: "あざ笑う瞳", x: 155, y: 241, w: 51, h: 46, damageRate: 0.1,
statusEffect: { type: "confuse", chance: 0.2 }, message: "思考がかき乱される!" },
{ name: "混沌の瞳", x: 188, y: 297, w: 51, h: 38, damageRate: 0.1,
statusEffect: { type: "random", chance: 0.5 }, message: "奇妙な何かが心に入り込む…" },
{ name: "探す瞳", x: 292, y: 125, w: 34, h: 48, damageRate: 0.1,
statusEffect: { type: "missrateup", chance: 0.3 }, message: "見透かされている気がする…" },
{ name: "迷える瞳", x: 290, y: 218, w: 53, h: 51, damageRate: 0.1,
statusEffect: { type: "accuracydown", chance: 0.3 }, message: "照準が定まらない…" },
{ name: "哀しい瞳", x: 123, y: 160, w: 59, h: 71, damageRate: 0.1,
statusEffect: { type: "atkdown", chance: 0.3 }, message: "力が抜けていく…" },
{ name: "疑う瞳", x: 92, y: 210, w: 40, h: 54, damageRate: 0.1,
statusEffect: { type: "evaup", chance: 0.3 }, message: "敵が見えづらい…" },
{ name: "おびえる瞳", x: 345, y: 232, w: 41, h: 42, damageRate: 0.1,
statusEffect: { type: "fear", chance: 0.2 }, message: "強い恐怖に襲われる!" },
{ name: "鋭い瞳", x: 121, y: 269, w: 36, h: 40, damageRate: 0.1,
statusEffect: { type: "critup", chance: 0.2 }, message: "敵が一瞬で襲いかかってきそうだ…" },
]
どうやらどこを攻撃しても、カウンター攻撃が返ってくる想定のようです。ひええ。
さすがにゲーム中盤でこんなボスが出てきたら、エンドロールまで頑張れる気がしない……!
基本は、背景設定と絡めた「セリフだけ」にして、状態異常も1つだけに絞った。
どこを攻撃したらどんなセリフが返ってくるのか? ぜひ楽しみながらプレイしていただきたい。
中ボスの名前の由来について
ちなみにこのエン=ソウルっていうボスキャラの名前、ChatGPTが考えてくれた名前案がビビッと来たので一発OKにしてしまったが……
よく考えると名前の由来が謎。
いまさらだけど、エン=ソウルの間にある=はどういう意味だろう・・・
🔍 「エン=ソウル」の「=」の意味考察
これは非常に面白いポイント!
「=」は日本の創作界で以下のような意味や効果を持つことがある:
1. 語の強調・接続(同一・融合・因果)
「エン」と「ソウル」が同一の存在であることを示唆。
「エン」が「ソウル」と同義であることを強調する。
例:「神=命」「ゼロ=神」などの構造。
2. 二つの要素の並列・融合の表現
「エン」が縁・炎・円・怨など多義的な意味を持つ場合、
「ソウル(魂)」との融合・対比として使われている可能性。
たとえば:「怨=魂」→「恨みそのものが魂となった存在」
または:「縁=魂」→「因果そのものが魂となった存在」など。
✅ 補足:命名的な観点
「=」がつくことで、名前に神格・象徴性が出る。
例:
ゴッド=カイザー(神=皇帝)
ジーク=フリート(欧風に見せるため)
なので、**「エン=ソウル」**は、
“ある名前(エン)と概念(ソウル)を強く結びつけた象徴的な存在”
だと解釈できるね。
ジーク=フリートって、ジーク・フリートとは違うってことだね?
「ジーク=フリート」のように 「=」でつなぐ表記 は、日本の創作物(特にファンタジーやRPG、アニメ、ラノベなど)でよく使われる手法で、以下のような意図が込められていることが多いです:
✅ たとえば:
「エン=ソウル」
→ 「エン(怨念/縁)」と「ソウル(魂)」が対等かつ一体化している存在、という含意を持たせているかもしれません。
普通の「エン・ソウル」ではただの人名っぽいけど、「=」だと禍々しい名前の記号性が強くなります。
「ジーク=フリート」
→ 単なる人名ではなく、「ジーク」と「フリート」が並び立つ伝説的英雄としての重みを出す。
なんか深い話になってきたゾ。
「エン(怨/縁)」と「ソウル(魂)」……この物語の設定にぴったりすぎるネーミングである。
シナリオをChatGPTと一緒に練り上げてきたからこそ生まれた名前かもしれない。
後日調べたところ、=は本来は「イコール」ではなく「ダブルハイフン」。
西欧ではハイフン「-」でつなぐ名前を分かりやすく表記したもののようだ。
ハイフン/ダブルハイフンでつながる名前とは、以下のようなもの。
両親の名字からとった「複合姓」:エドガー・アラン=ポー
(彼の場合は、育ての親アランと生みの親ポーから取ったらしい)
聖人のsaintがくっついた名前:サン=テグジュペリ
普通は別々の名前を合体させた「複合名」:ジャン=ポール・ゴルティエ
ゲームやラノベで使われるようになった経緯の裏取りはできなかったが……
「なんか由緒正しい名前っぽくてかっこいい!」という理由でラノベなどで使われるようになり、それらを学習したChatGPTが提案した名前なのかもしれない。
次回はタイトル画像や、戦闘前の演出について記載予定!