ベーマガ世代、ChatGPTとゼロからRPGを作る旅
第14章:祭りイベント、クエストごとの状態セット、ミニパズル
祭りイベント
さて、今度は楽しい話、イベント作りに戻ろう。
イベントシーン作りは楽しい反面、作成・テストともに大変な作業でもある。
おそらく、RPGツクールなどを使っていても結構大変だろうと思う。
誰がどんなセリフを言って、誰がどこへ何歩歩いて、どこで音を鳴らして……
間に一つ追加すると、連番がずれてまたバグる。
条件分岐などもあってさらに大変。
番号がずれるたび、
"1": { type: "message", text: "宝箱の中は空っぽだ。", next: "2" },
のように""を打つのも、次の行を指定するのも面倒になったので、
1: { type: "message", text: "宝箱の中は空っぽだ。"},
のように少しでもタイプを減らせるように、自分でソースを書き直し、極力省力化する工夫もしたが、それでも番号がずれるたびに一仕事である。
今回はゼロからプログラム制作しているため、「イベント上のバグ」と「プログラム側のバグ」が混在していて、さらにややこしい。
祭りの踊りのシーンを作っていたら、主人公だけ移動時に移動方向を向いているバグが発生。
一人だけキョロキョロしていて落ち着きがない。
それを直そうとしたら、なんとダンスの最中に、一人で画面の外まで歩いていってしまい、笑い転げたことも。
▲ 一人だけ、振付を覚えていない人みたいになっている。
なんだかんだ祭りの曲を2曲作る羽目になり、あれやこれや入れているうちにどんどん長くなってきたので、これを3ヒロイン分作ると思うと気が遠くなってきた。
とりあえず選んだヒロインを変数にして共通化できる部分は共通化。
この辺りはプログラムを組んでいるがゆえに効率化できる部分ではある。
いったんできあがった本イベントスクリプト、行数にして380行ぐらい、文字数が21,796文字。
原稿用紙に詰め詰めで書いても、55枚分はある。
そら時間かかるわけだわ。
……いやぁ、RPG作りって本当に大変……
クエスト進行状況に応じた「世界」の変化
長いイベントがあると、2度目はスキップしたくなるのが人情である。
こんな素人RPGを2度もプレーする人はいない……? という気もしたが、ヒロインごとに結構シナリオが変わるので、2周目・3周目プレーしてくださる方がいるかもしれない。
何よりテストプレーを繰り返している自分自身、飛ばせないときつい。
ということで、ESCでイベントをスキップする機能を追加したのだが、タイミングを見計らわないと、画面が暗転したままになったり、主人公がすっ飛んでいったり妙なことになる。
音楽、NPCやプレイヤーの配置なども、イベントのbefore,afterで変わっていたりする。
だいたい、今の規模感では、頭から通しでテストプレー&デバッグを繰り返すのはもう無理。
クエストの進行状況によって、NPCの居場所やBGMなどを管理する必要が出てきた。
というか、セーブ→ロードすることも考えると、この機能は絶対に必要じゃあないですか。
ロード時、イベントスキップ時やテストプレイなどのため、
クエストの状態に応じてNPCやアイテムの初期配置を変える機能を追加したいんだけど、どう実装するのがスマートかな。
たとえば元の定義に spawnRules という配列を追加しておきます。各ルールには、どのクエストのどの状態のときに「表示/非表示」「位置をどう変更するか」を書いておきます。
2. 初期化のタイミングで「実際に置くリスト」を組み立てる
ゲーム開始時やセーブロード後、あるいはクエスト状態が変化した直後に以下のような関数を呼び出します。
3. 進行度が変わったら差し替えを呼ぶ
イベントでクエスト状態を更新した直後にも同じ buildNpcList() を呼べば、今いる NPC を入れ替えたり移動させたりできます。
export const baseNpcDefinitions = [
{
id: "", name: "クレイグ", image: "tileset_castle", x: 5, y: 6, map: "village",
// 進行度別ルール
spawnRules: [
// main1 が start ならこの位置に
{ quest: "main1", state: "start", x: 5, y: 6, visible: true },
// main1 をクリアしていたら屋敷に
{ quest: "main1", state: "complete", x: 20, y: 19, map: "mannerhouse", visible: true },
// main1 を開始していなければ非表示
{ quest: "main1", state: "none", visible: false }
]
},
// 他の NPC も同様…
];
function buildNpcList() {
const result = [];
for (const def of baseNpcDefinitions) {
// まず「そのマップに出るか」のデフォルトを見ておいて
let entry = { ...def };
// その NPC の spawnRules を探す
const rule = def.spawnRules?.find(r => {
return getQuestState(r.quest) === r.state;
});
if (rule) {
entry = { ...entry, ...rule };
}
result.push(entry);
}
return result;
}
話しかけられるタイプのNPC以外に、プレイヤーが乗ると発動する、ドアなどのeventObjectもあった。 こちらもイベント状況やフラグに応じ、出現を切り分けたいと相談すると、
const eventObj = [ { name: "秘密のドア", x: 5, y: 4, map: "test", image: "test",
imageNo: imageNo: 1, get disable() { return !flag.open; } }, ];
のようにgetterを書き、if(eventObj.disable)など条件分岐でそのオブジェクトが「無効」であることを判定する案を提案された。
get/setという関数を経由するgetter/setterは、java(javascriptでなく本家java)やpythonなどのオブジェクト指向言語でも昔からおなじみなので私にも理解しやすい。
が、この場合は、カプセル化したクラスの内部変数を外部から参照・更新するために使うためのものだったはずだ。
オブジェクトをconst(定数)として宣言しておいて、中の変数をいじるのにgetter/setterを使うなんて手法は知らなかった。
ちなみに、オブジェクト全体を定数でなくlet(変数)にするのは、メモリ効率がよくないそうだ。
そもそも定数で宣言したものの中味をいじれるというのも、考えてみると変な話である。
実はconstは本当の意味での「定数(変えられない値)」ではなく、「再代入不可」「参照先が固定」されるだけらしい。
const a = 0;
a = 1;
などとすると再代入時にエラーになるのだが、オブジェクトにして、
const a = {key:0};
a.key = 1;
とするのはOKなのである。それなら中に変数を入れて
let x = 0;
const a = {b:x};
x = 1;
のようにしても問題なさそうなものだが、これだとa.b = x = 0のまま宣言時に固定されてしまい、a.b = 1に変更されない。
この場合、getterを使って
let x = 0;
const a = {get b(){return x;}};
x = 1;
のようにするとa.b = 0からa.b = 1に変更できるということだ。
※すみません、一部誤記があり修正しました。(2025/8/31)
……分かりづらい!
自然言語である日本語や英語が分かりづらいのはともかく、なぜ人工言語のプログラムでさえ、大半の人間が「?」となるルールや、誰でも踏みそうな地雷が絶えないのか、理解しかねる。
この先AIが進化して、「誰でも直観的に分かる」プログラム言語を作ってくれるとしたら、どんなものになるんだろう。
逆に「人間にはとうてい理解不可能な言語」を大量に生みだしそうな気もするが。
クエストの状況に応じてNPCやゲーム全体の状況を設定する関数を作ろうと思ってるんだけど、なんて名前がよさそうかな?
こんな名前はいかがでしょうか?
1. updateWorldByQuestState()
– 「クエストの状態に応じて(World=世界全体を)更新する」イメージ。
2. applyQuestProgress()
– 「クエスト進行度を適用する」という抽象度高めの名称。NPC・アイテム配置からBGMや背景変化までまとめて。
3. syncGameStateWithQuests()
– 「クエストとゲーム全体の状態を同期させる」というニュアンス。
4. refreshEntitiesForQuest()
– NPC やオブジェクトなど「Entities(エンティティ)」を対象にしていることを強調。
5. initializeWorldForQuestState()
– ゲーム開始時/ロード時に一発で呼ぶイメージがわく、少し長めの名前。
世界全体を更新! なんだか壮大だが、1案を選ぶことにした。
BGMについても、マップとクエストの進行状況によって変える必要がある。
ひとつひとつ条件分岐で設定していたのだが、だんだんしんどくなってきた。
イベントや演出が進むたびにBGM条件分岐が増えていくと、最初は簡単でもすぐ管理不能になります。
________________________________________
✅ あなたのコードは頑張ってる!でも……
今のように if (map === "myvillage") { ... } にどんどん分岐が増えていくと、
• マップの種類が増えるたびに if の数も爆増
• クエストが複雑になると、音の優先順位管理がカオス化
• 「いつ、どこで、何が鳴るか」が見通せなくなる
________________________________________
✅ 解決策:getCurrentBGM(map, questLog) に分離する
そーですか。ですよねえ……
ということで、ベテランの忠告に従って、getCurrentBGMなる関数を作ることにした。
const bgmRules = [
{ condition: (map, q) => (map.startsWith("my") ||
["mannerhouse","chapel","graveyard"].includes(map)), bgm: "village" },
...
{ condition: (map, q) => map.startsWith("southforest") , bgm: "darkplace" },
{ condition: () => true, bgm: "" }
];
function getCurrentBGM(map, questLog) {
for (const rule of bgmRules) {
if (rule.condition(map, questLog)) return rule.bgm;
}
}
配列にオブジェクトに並べて、それぞれにアロー関数を入れて条件式にする……
jsならではの記載方法だろうか。
本当に、なかなかアロー関数に慣れない。
なお、これを入れただけだと、クエストが変わったとたんに曲が冒頭から流れ直したりしておかしくなったので、「前の曲と変わった時だけ流し直す」などの処理が必要になった。
ミニパズル
クエストやフラグに応じてNPCを移動させたり、オブジェクトを出したり消したりを制御できるようになったため、結構難解なこともできるようになった。
神殿内のミニパズルで、「石像を動かすと、空中に橋が伸びていく……」なんて、夢見ていた演出も実現できた。
▲ 作るのはたいへんだけど、想定通り仕掛けが動いた時は達成感がある……!
ただ、橋が現れない状態で空中を歩けるようになるのを防ぐため、表示/非表示だけでなく、「透明NPCで歩ける場所をふさぐ」など、大量の処理が必要になり、デバッグがかなりしんどかった。
(すべてをイベントスクリプトだけで処理するのがあまりにもしんどかったので、少しだけ専用関数も作った)
次回は小ボス戦と状態異常処理、小ボスに至るまでのマップ構成検討について……!