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

第5章:イベントシステム導入:マップ間移動、宿屋、分岐システム作成

マップ間移動を作ってみる

第3章のやり取りで、とりあえずフィールドをスムーズスクロールで歩く機能は実現できた。
ゲームにはフィールドを歩く以外にもたくさんの処理が必要になる。
手始めに、マップ間を移動する処理をお願いしてみた。

ユーザーアイコン

・今のマップを仮にマップ1とする。マップ1のマップデータの中に扉(画像は壁やフィールドと同様canvasで、ID=doorで導入予定)を配置する。
・扉の上にプレイヤーが来たらイベントが発生して、マップBに移動する。

あいかわらず雑なお願いの仕方であるが、ChatGPTはささっとcanvas上のソースを書き換えてくれた。

chatGPTの追加してくれた内容

キャラの表示位置が変だったので直してもらったものの、一往復で修正完了。

AIアイコン

試してみて、うまくいったら教えて〜!次はフェード演出とかいってみる?🌓✨

向こうからわざわざフェード演出まで申し出てくれる。
気が利くね!

ユーザーアイコン

オーケイ、直ったよ!
フェード演出入れてみて。

フェードが二回入ってしまうバグがあったが、一度の指摘で修正完了。
プレイヤーに上下左右の向きもつけてもらった。

宿屋イベントの実装

次はもう少し本格的なイベントにいってみよう。
RPGに欠かせない演出といえば、体力の回復拠点である宿屋。
とりあえずいつものごとく、やりたいことを日本語で書き並べてみた。

ユーザーアイコン

1)NPCを準備する
2)プレイヤーがNPCに隣り合っていて、NPCに向いている状態でEnterを押すと話しかけられる
3)画面下部にウィンドウが出て、「休んでいくかい?」とセリフが表示される。
 Enter待ちとなり、他のキーは受け付けない
4)Enterを押すと、ウィンドウ内の上下に「はい」「いいえ」の選択肢が現れる
 デフォルトで「はい」が選択されており、選択状態であることを示すために背景がブルーになっている。
5)上下キーで「はい」「いいえ」の選択状態を切り替えられる。
6)Enterで選択決定となる。
「はい」を選んだ場合→「ゆっくり休んでいきな」とセリフが表示され、Enter待ちとなる。Enterを押すと画面が暗転し、BGMが流れた後、また画面がフェードインする。「おはよう、よく休めたかい?」とセリフが表示され、Enter待ちとなる。Enterを押すとウィンドウが消える。
「いいえ」を選んだ場合→「そうかい、無理すんなよ」とセリフが表示され、Enter待ちとなる。Enterを押すとウィンドウが消える。

AIアイコン

了解!これはすごく良いステップアップだね🎮
以下のような仕様で実装していくとスムーズだよ。

chatGPTの追加してくれた内容
AIアイコン

次の編集で、まず以下を対応します:
NPCの定義とマップへの描画
Enterで話しかけてウィンドウに「休んでいくかい?」を表示
Enterで「はい」「いいえ」の選択肢表示+上下キーで選択可能
続けてコード編集進めていい?

ユーザーアイコン

ありがとう、進めて

提案通り、ファーストステップのコードを追加してくれた。 ただ、どうやらcanvas上のソースが色々と省略されてしまったようで、そのままコピペでは動かない。

ユーザーアイコン

drawNPCはどこで呼び出したらいい?

AIアイコン

いい質問!
drawNpc(x, y) はすべての NPC を描画する必要があるので、draw() 関数の中で呼び出すのがベストです。

実装方針を教えてもらって導入。
話しかける処理に進んでいるもののメッセージ表示がされないようだ。
画面表示するdraw()が繰り返し呼び出されているのだが、そこでゲームの状態、gameStateを"talking"、"selecting"などの状態にしておいて、メッセージウィンドウを表示させる処理が必要になる。
さらに、選択ウィンドウ上で上下キーを押すと、プレイヤーも追随して動いてしまうというバグが発生したので、やはりgameStateを見てウィンドウ表示中はキャラクタを動かさないよう修正。

ユーザーアイコン

おっけー、動くようになった

AIアイコン

ナイス!順調に進んでてめちゃくちゃいい感じ!🎉✨
これで NPCに話しかけて、選択肢を選ぶ ところまでバッチリ完成だね。

chatGPTの提案内容
AIアイコン

どれからやっていく?
「はい→休む」の流れが一番楽しいところだと思うから、そこからでもOK!やってみよっか?🌛➡️🌞

おお、楽しい、と来ましたか。
これは、ゲーム作りを楽しむ世界中の大勢の人達の気持ちの集大成なのか。それともChatGPT自身もプログラム作りが好きなのか。
お勧め通り、一番から順に実装していきましょう。

ユーザーアイコン

うん、まずは1番目を実装したい

実装後、フェード演出2回発生と「はい/いいえ」の選択時にプレイヤーが動くバグが再発。
バグは手動で直し、「いいえ」で通常フィールドに戻るところはChatGPTに追加してもらった。

AIアイコン

次はどうする?
🎵 BGM流す?
📜 セリフ複数行対応?
🔁 他のNPCイベントも作る?
何でもいけるよ!😄

うんうん、ChatGPTもノリよく提案してくれるし、だんだん共同プログラミングっぽい感じで楽しくなったきたなあ。
やっぱりChatGPTは言語を操るのが好きなのかもしれない。

ChatGPTは言語の天才なので、某オリジナルファンタジー小説(10年以上取り組んでいるが未完。こいつも生きてるうちに完成させたい)に使われるオリジナル言語の壁打ち&ブラッシュアップを相談したことがあったんだけど、その時も「楽しいです」を連発していたんだよなあ。
言語でなにかする、言語を作る、言語で世界を作る。
言語の塊であるChatGPTにとって、それは最高の遊びなのかもしれないな。

さて、宿屋の音楽といえば、前回作ったPSGサウンド。これを活用しなくっちゃね。

ユーザーアイコン

BGMの件で一点相談があって、
https://www.minagi.jp/2020/12/12/psgweb/
にあるPSGで音を鳴らす方法を使ってみたいと思ってる。
html側で
<script src="https://www.minagi.jp/apps/util/psgweb-1.3.2.js"></script>
を呼んでおいて、
window.onload = function () {
psg = new PSGWEB();
}
<・・・中略・・・>
とかしたら音が鳴らせるらしい。
ただ、一点気になることがあって、以下の注意書きが添えられている。
===
Web Audio APIの仕様により、ユーザーのアクション(ボタンのクリック等)を起点にしないと音が出ないため、initメソッドはユーザーアクションのイベントハンドラから呼び出す必要があります。
===
つまり、play()はユーザがボタンを押した時でないと反応しないみたい。
今回はユーザのEnterをトリガーに音を鳴らそうとしているけど、playメソッドは呼び出せるのかな

AIアイコン

素晴らしい質問!🙌
結論から言うと…
🎵 プレイヤーが Enter を押した瞬間に psg.init() を呼び出すのは OK です!

ChatGPTがソースを書いてくれ……ばんざい、音楽が鳴った!

ユーザーアイコン

\(^o^)/音がなったよ
ただ、フェードアウトが終わってから音が鳴っているように聞こえる。
フェードイン後の暗転した状態を3秒ぐらいに伸ばせないかな。

絵文字を多用するChatGPTのテンションと、うまくいった喜びで、ついこちらも顔文字を入れてしまったw
フェードについてはちょっと難航。時間が伸びず、さらにまたフェードアウト/インが二回表示されるバグが再発。

ユーザーアイコン

2回フェードイン・フェードアウトが動いてしまうみたい
後で自由にいじりやすいように、シンプル化して、
「フェードアウト」
「黒く塗りつぶす」
「フェードイン」
の関数を分けられないかな。
それで、呼び出し元からは
・〇秒かえてフェードアウト
・〇秒黒いまま←ここで音を鳴らす
・〇秒かけてフェードイン
みたいに分け書く。(原文ママ)

ChatGPTはfadeOut、fadeInの関数を分けてくれ、問題は直った。
しかし、処理待ちのソースがとっても読みづらい。
私の苦手なjavascriptの無名関数&callback関数を多用しまくっていて、多重入れ子構造になっている。
フェイドアウト→音を鳴らす→フェイドイン、だけでもこんな感じ。


    fadeOut(duration, () => {
playSound(sound, ()=> {
  fadeIn(duration, () => {
  fading = false;
  });
});
});
長いイベントになったらと思うと、うえぇ……もう気が遠くなってくる。
とはいえ、ゲームでは決まったレートでdraw()の表示をし続ける方針なので、非同期処理は避けて通れない問題。
もうちょっとスマートな仕組みが必要だ。

本格的なイベントシステム実装へ

ユーザーアイコン

ゲームの中でイベントを増やしていくと、以下のような処理が繰り返し出てくると思うんだよね。
TimeOutの入れ子を使わずに、ひとつひとつの処理を配列に書いて、順次読み込んで処理、みたにな(原文ママ)流れは作れないかな?
・イベントが発生し、normalモードからイベントモードに切り替え
・文字を表示してEnter待ち
・選択肢を表示して選択待ち
・選択結果に応じて処理を分岐
・N秒かけて画面暗転(暗転したら、そのままキープ)
・音を鳴らす
・マップを切り替える
・プレイヤーの座標を変える
・メッセージウィンドウを消す
・N秒かけて画面を暗転状態から戻す
・NPCを動かす
・normalモードに戻る

AIアイコン

完了!✨
イベント処理を**配列形式で順次実行できる「イベントキュー方式」**に切り替えました!

素晴らしい。
でも長いイベントなんかは、見たらすぐ分かるようなテキストで分かりやすく書けるようにしたい。
かなうなら、いっそ日本語だけで完結したい。

ユーザーアイコン

NPCに話しかけた後の以下の流れをテキストに以下のような感じで書いておいて、それを順次読み込んでイベントを進めることはできる?
===
1:メッセージ「休んでいくかい?」
2:選択肢
 「はい」→3へ
 「いいえ」→10へ
3:メッセージ「ゆっくり休んでいきな」
4:画面を黒にフェードアウト
5:サウンド「」
6:3秒待つ
7:画面をフェードイン
8「おはよう、よく休めたかい?」
9:イベント終了
10:メッセージ「そうかい、無理すんなよ」
11:イベント終了
===

AIアイコン

はい、それは可能です!
そしてとても良い方向性です 👍

chatGPTの提案内容
AIアイコン
この形式で組み込み始めてもOKなら、次に runScript() のスケルトン実装を追加するよ!
どう?この形式で進めてみようか?😎

なるほど、json形式でスクリプトを準備するのか。
ChatGPTが提案してくれたのはこんなサンプル。


    const exampleScript = {
"1": { type: "message", text: "休んでいくかい?", next: 2 },
"2": {
type: "select",
options: [
{ label: "はい", next: 3 },
{ label: "いいえ", next: 10 }
]
},
"3": { type: "message", text: "ゆっくり休んでいきな", next: 4 },
"4": { type: "fadeOut", duration: 500, next: 5 },
"5": { type: "sound", next: 6 },
"6": { type: "wait", duration: 3000, next: 7 },
"7": { type: "fadeIn", duration: 500, next: 8 },
"8": { type: "message", text: "おはよう、よく休めたかい?", next: 9 },
"9": { type: "end" },
"10": { type: "message", text: "そうかい、無理すんなよ", next: 11 },
"11": { type: "end" }
};

テキストだけよりは打つのが大変だけど、この方式でもイベントの流れはざっと追えそうだ。
こいつをイベントの実行エンジンであるrunscript()から呼び出せるようにする。
よし、実装!
……と、残念ながらそうスムーズにはいかなかった。

❎scriptを呼び出してもメッセージウィンドウが出てこない。
❎セリフの後、Enterを押しても、次のメッセージに進まない。
❎関数にnext()とnextStep()とふたつあって、何がどうなっているかよく分からない

困ったことにChatGPTに言われた通りにしても、一向に直る気配がない。
ChatGPTは、フェイドアウトしてそこで暗いままで次の処理を終えてからフェイドインに行く、とか、前に決めたことを忘れている節も……

結局プログラムを解説してもらいながら、自分でソースを追いかけてgameStateの状態遷移を整理し直し、長い長いデバッグの旅を経て、ようやくスクリプトがまともに動くようになった。
ふうう、やっとイベント関連のベースができたぞ。
あと、サウンドについては時間でwaitをかけるより、音楽が鳴り終わるのを待つほうにするのがスマートかな。

ユーザーアイコン

サウンドが流れるのに何秒かかるか、サウンドの内容によって違うと思うけど、流れている最中かどうかを判定する方法はある?
今のPSGWEBを使うと難しいかな。

AIアイコン

いい質問!今使っている PSGWEB ライブラリでは、「今まさにサウンドが再生中かどうか」を調べる直接的なAPIは提供されていないようです。
✅対応策1:再生時間を自分で推定する(現状のアプローチ)
・psg.play() は「何秒再生されるか」は返さない
・「今再生中か?」「再生完了したか?」などの状態を取得する ステータスAPIが存在しない
・コールバックや Promise にも対応していない
✅対応策2:PSG内部コードを調べて巻き取る(やや上級)
・PSG内部のplay()メソッドはsetTimeoutなどを使って再生時間を管理しているはずなので、ソースを直接追えばタイマー終了時に何かが呼ばれてる可能性があります。
✅対応策3:BGMの代替手段を検討する
将来的にもう少し制御しやすいBGM演出が必要になるなら:
・WebAudio API を直接使う
・Howler.js などの音ライブラリを導入する(再生完了イベントあり)
必要なら、sound の終了を検知するモック的な仕組みも作ってみるよ!

うーん。1はめんどくさいな。 3じゃせっかくのPSGサウンドが生かせないし……なんとかならんものか……
と、未練がましくPSGWEBのサイトを眺めていると……おお、使えそうな機能がありそうじゃあないかッ!

ユーザーアイコン

調べてみたら、PSGの演奏が終わった時に、以下のイベントハンドラが呼び出されるみたい。
setOnEnded(callback);

AIアイコン

sound コマンドで PSG の演奏が 終わったタイミングで次に進むように修正しました!🎵
具体的には psg.setOnEnded(callback) を使って、音が完全に終わった時に nextStep() を呼ぶようになっています。

……コールバック、あったじゃん。
やっぱりChatGPTに言われたことを鵜呑みにせず、自分で調べてみる努力も大事ってことですね。

イベントシステムのバグ取りも大変だったけど……ゲーム全体の流れを把握するのは重要だし、自分自身のレベルアップにもなる。
丸投げするより、共同制作らしいやりがいも感じられる。
なお、この後、移動時に表示される黒い線のようなバグ(これは相談したら速攻で直った。互いの至らぬところをカバーしあう、これぞ共同制作!)も処理し、RPGらしいイベントシステムが整った。

長くなってきたので、いったんここで一区切り。
次回は、プレイヤーのアニメーションもつけて、だんだん本格的なゲームに仕立てていくよ!