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

第18章:終わらぬバグとの戦いと調整、プレイヤーからのご意見対応

今回はゲーム作り最大の難関ともいえるデバッグや細かい調整について。
プレイヤーからのフィードバックの調整についても……!

同期問題再び

第5章で導入したイベントシステムによって、イベントシナリオの順序制御などは実装できた。
が、非同期処理周りでは、本当にバグが多く、悩まされる。
ちょっといじるとコールバックの走る順序が変わり、思いがけないところでバグが発生。
ロードした後にいきなりセーブスロットを選ぶスクリプトに飛んだりする……OTL

そもそも、コールバック処理はバグの温床である。
たとえば、「ダメージを受けた」と表示し、スタミナをダメージ分削る、という処理を以下のように作ったとする。

   
  let stamina = 20;
let damage = 0;

function punch(){
damage = 10;
console.log(`${damage}のダメージを受けた!`);
}

function battlemain(){
punch(); // ①パンチを繰り出し、ダメージをメッセージ表示
stamina -= damage; //②スタミナをダメージ分削る
console.log("残りスタミナ:",stamina); //③
}
battlemain();
battlemainが呼び出され、①→②→③の順に処理が走る。
最初のスタミナが20。ダメージが10入って、パンチを受けた後のスタミナは10。
コンソールには以下のように表示される。
 >10のダメージを受けた!
 >残りスタミナ:10
ここに「パンチの音」を入れて、音が鳴り終わってから「ダメージを受けた」と表示したくなったとする。
既にplaySoundという関数が用意してあり、一つ目の引数にcallbackを渡せるようになっている。
音が鳴り終わったらcallbackが呼び出されるので、そこにダメージ表示を入れる。

   
  let stamina = 20;
let damage = 0;

function punch(){
playSound("punch",()=>{ //パンチ音が鳴り終わったらダメージの表示
damage = 10;
console.log(`${damage}のダメージを受けた!`);
});
}

function battlemain(){
punch(); // ①パンチを繰り出し、ダメージ計算
stamina -= damage; //②スタミナをダメージ分削る
console.log("残りスタミナ:",stamina);
}

battlemain();

これを実行すると、ダメージ計算をする前にpunch()からbattlemain()に処理が戻って②が実行されてしまう。
つまり、表示が
 >残りスタミナ:20
 >10のダメージを受けた!
と逆になり、スタミナも20のまま減らない。

まあ、このぐらいなら見れば分かるが、実際にバトルシーンでの非同期処理は、サウンドだけでなく、
💬メッセージ表示
🎵サウンド
🎞️アニメーション
が入り乱れて動くので、何がどの順で実行されるか訳が分からなくなってくる。

結局、戦闘シーンでは、 callback処理からpromise/awaitに書き直すことにした。
promiseは「処理が終わったら教えるからね」と約束しておくようなもの。
処理が成功した場合は、「解決した(処理が正しく終了した)よ」という知らせにresolve()という関数を呼び出す。
失敗した場合はreject()を返すようだが、今回はいったん無視。
呼び出し元の関数にはawaitを先頭につけておく。

先のサンプルをpromise/awaitで実装するとこうなる。

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

function playSound(name, callback = null) {
const audio = sounds[name];
if (!audio) return
audio.play();
return new Promise(resolve => { //←処理の終了を通知
audio.addEventListener("ended", () => {
if (typeof callback === "function") callback();
resolve();
});
});
}

async function punch(){ //←awaitを使うfunctionには、asyncをつける
await playSound("punch"); //←awaitをつけて、音が鳴り終わるまで待つ
damage = 10;
console.log(`${damage}のダメージを受けた!`);
}

let stamina = 10;
let damage = 0

async function battlemain(){ //←awaitを使うfunctionには、asyncをつける
await punch(); //←awaitをつけて、punchが完了するまで待つ
stamina -= damage;
console.log("残りスタミナ:",stamina);
}

battlemain();

①呼び出し先の関数(playSound)をpromise対応にする
②呼び出す際、先頭にawaitをつける
③呼び出し元の関数(punch)にasyncをつける
④punchの関数の呼び出しもと(battlemain)でも同期処理が必要なら、そこにもawaitをつける
⑤その関数の先頭にもasyncをつける

うーん……どこまでカスケードされるんだ……という感じで、これはこれでなかなか難儀である。
とりあえず今回は戦闘内でコールバック関数を使っているところだけasync対応したが、本当はプログラム全体を見直さないとならないかもしれない。

ちなみに、playSound()側にコールバックを残しているのは、既存の他のソースの動作保証のため。
また、これが必要になるケースもありそうだからである。
たとえば、
音が鳴るのと同時にアニメーション開始、ただし次のターンに進むのは音が鳴り終わってから……
みたいな演出も考えられる。

メッセージウィンドウへのテキスト表示メソッドでも対処が必要になった。
以下のように第二引数のonConfirmにコールバック関数を引き渡していたのだが、


   
  setText(texts, onConfirm = null, speedRank = "normal", fraq = 800)

こいつをラップして、以下のような同期用テキスト表示関数を作った。

   
  asyncSetText(text, speedRank = "normal", fraq = 800) {
return new Promise((resolve) => {
this.setText(text, resolve, speedRank, fraq);
});
}

呼び出し元では、先頭にawaitをつけてメッセージ表示完了まで待ちとする。

   
  await messageWindow.asyncSetText([`経験値${totalExp}、${plusGold}Gを手に入れた。`], "battle");

修正がたった4行で済んだのはChatGPTのお陰。
自分一人だったらおそらく途方に暮れていた。
setTextに元々あったcallbackにresolveを入れれば、処理終了後にresolve()が呼び出される仕掛けである。

ちなみに最後の"battle"という引数は、バトルの時はテンポよくテキストを高速で表示するように工夫した涙ぐましい努力だ。

シナリオ上、「緊迫感を持たせる速いセリフ」「普通のセリフ」「ゆっくり話す重々しいセリフ」があるのだが、それと別に 「設定」からテキスト全体の「速い」「普通」「遅い」も選べるようにした。

   
  const textSpeedProfiles = {
slow: { normal: 120, tense: 50, grave: 160, battle: 10 },
normal: { normal: 95, tense: 45, grave: 140, battle: 10 },
fast: { normal: 50, tense: 10, grave: 80, battle: 10 },
};

今どきslowを選ぶ人はいない気もするし、もう少し調整するかもしれない。

なお、セリフを飛ばしたり早送りしたりする機能は、さんざんテストしたあげく断念。

飛ばす機能を見送ったのは、自分でテストしていても、つい「無意識にEnter連打し、大事なセリフを読み飛ばしてた!」ってことが頻発したからだ。
「まともに文章を読めない症候群」にとうとう私も侵されたのか……
情報の波に飲まれ、忙しくなりすぎた時代の弊害かもしれない。

早送りのほうは色々苦戦してみたものの、どうにも動作が安定しないし、「ボタン長押し中だけ早送り」というのが、それはそれでストレスフル。
このゲームはセリフを読み飛ばすと結構致命的なので、せっかちな方や二周目の方は、申し訳ないが「速い」で我慢いただきたい。
あまりにも長く続くセリフは、ESCでのスキップも追加している。

それにしても、同期/非同期ではあちこちで何度もバグが出て、デバッグが本当に大変。
どこかで全面見直しが必要かなぁ……

サウンドの重ねがけ

戦闘中で回復サウンドがちょっと長めなのだが、エンターを押して次のターンに入ると音がブツッと途切れる事態が発生し、「重ね掛けOK」に作りなおした。
chatGPTに相談したら、こちらはラップするのでなく、同じplaySoundの中をいじって、awaitで呼び出せるようになった。

   
  function playSound(name, callback = null, volume = 0.6, allowOverlap = false) {
if (debug) console.log("playSound", name, callback, volume, allowOverlap);
const original = sounds[name];
if (!original) return Promise.resolve();
if (!allowOverlap) {
// 上書き用チャンネル 既に再生中のものがあれば止める
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
currentAudio = original.cloneNode();
currentAudio.volume = volume;
currentAudio.play();
return new Promise(resolve => {
currentAudio.addEventListener("ended", () => {
if (typeof callback === "function") callback();
currentAudio = null;
resolve();
});
});
}else{
// 重ね再生OKチャンネル
const audio = original.cloneNode();
audio.volume = volume;
overlayAudios.push(audio);
audio.play();
return new Promise(resolve => {
audio.addEventListener("ended", () => {
overlayAudios = overlayAudios.filter(a => a !== audio);
if (typeof callback === "function") callback();
resolve();
});
});
}
}

うーん、長い。
もっとスマートな書き方もありそうだが、動いているので、いったんこれで進める。

サウンドについては、環境音(ambient)を新たに追加することにした。
レトロゲームだからBGMとSE単音だけいいかと思っていたのだが、素材屋さんのリッチなサウンドを使い始めた時点で、ちょっと無理があったと思われる……

おそらくこの三種(BGM、SE(サウンド)、環境音)に分けるのがゲーム作りでは王道。
環境音は急に消えると違和感があったので、フェイドアウト機能もつけた。
残念ながらBGMはPSGサウンドのため、フェイドアウトせずブツッと曲が切り替わってしまう仕様である。

デバッグ、デバッグ、またデバッグ

いったん中ボスまでつなげた後、微調整やらバグフィックスやら100個ぐらいTODOリストを作ってテストプレイを始めたのだが、
・プレイしていると問題に気づく
・問題を直すと、修正により別の場所で新たなバグが生まれる OTL
ということで、かれこれ200箇所から300箇所ぐらいは手を入れることになった。

バグには、文章のtypoやドット欠けのような可愛いものから、
市販ツールを使っていたら絶対起きないであろうビックリなものまで……

📜 ビックリなバグリスト

いやはや、超常現象だらけでした。

そのほかの調整

敵の出現量の調整はもちろんのこと、強さの調整も結構必要だった。
「レベルごとにこの辺の敵を倒せるように……」
というのはテストプレイでも想定していたのだが、マップ移動しながらどのぐらいレベルが上がるか、というのを確かめないとダメだ。
なるほど、難しすぎて脱落者続出(ロンダルキア洞窟あたり)のドラクエ2の現場では、おそらくこんなことが起きていたのか……と体感で分かった。
(ランダムエンカウントのため、思いがけず敵が多すぎたり少なすぎたりということも起きる)

さらに、序盤お金が溜まらなすぎて、まともな装備が買えない、という事態が発生した。
しかも、お金がないので宿屋にも泊まれない!
とはいえ、序盤のカラスやスズメバチや蛇を倒したぐらいで大金がたまるのは納得がいかないので、

 ✅無料で泊まれる場所を作る
 ✅序盤の敵がそこそこの確率でアイテムをドロップするようにし、売れるようにする
 ✅町の人達にヒントをばらまく

また、主人公が魔法を使えないので、ワンパターンな戦闘に飽きないよう、

 ✅投擲アイテムのほかに、敵を回避できなくしたり、「力溜め」を解除したりするフィールドアイテムを作り、戦略的に戦えるように
 ✅町の人にヒントを言わせる

という調整を行った。

フィードバック調整

自分で調整できるところはどうにか調整したので、次はテストプレイのお願い。
前提知識のない状態で遊んでくれた人達からのフィードバックはとても大切だ。

すぐに、スマホで音楽がおかしくなってハングアップするという恐ろしい問題が発覚した。
まずは画面の縦横切り替えると、BGMがおかしくなるらしい。
こちらは、縦横画面切り替えが発生した際に、以下のように音楽を初期化し直すことで修正できた。


window.addEventListener("orientationchange", () => {
resizeGameCanvas();
if(psg && bgmOn && bgm){
psg.stop();
psg.init(() => {
psg.play(...(psgtracks[bgm] || []), 0);
});
}
});

また、通信をズバッと切ってみると、サウンドが鳴る部分でフリーズすることが分かった。
最初にまとめて読み込んでいるつもりだったのだが、その都度サーバに取りに行く仕様になっていたようだ。
通信環境の悪いところでも遊べるように、こちらも、以下の読み込みを追加し、サクッと完了した。


Object.values(sounds).forEach(audio => {
audio.preload = "auto";
audio.load();
});

「直したプログラムがなかなかサーバに反映されない」という問題もあった。

キャッシュさせないためのメタタグも入れたがダメ。


<meta http-equiv="Cache-Control" content="private, no-cache, no-store, must-revalidate, max-age=0">

ちゃんと「キャッシュバスター」も入れてある。
ファイル名の末尾に以下のように日時を入れて、古いコードは使われないようにする仕組みだ。
(本当はバージョン番号などを入れたほうがよいのだが、α版だしとりあえずこれで)


<script src="js/setparams.js?v="+ Date.now();></script>

どうもサーバ移転してから反映が遅い気がしたので、ChatGPTに相談し、サーバ上に以下の項目を記載した.htaccessファイルを配置した。


Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires 0

これでようやくキャッシュ問題が解決された。ふうぅ。

バグではないが、ユーザビリティ的に致命的な問題も発生した。
「村を出てから、隣町にすらたどり着けない」
という苦情が(!)
至急、対策を検討することにした。

実はこのゲーム、序盤のフィールドでは「道を一歩でも外れる」と複数敵が出てくる。
レベル1の状態では、ほぼ勝てない仕様だ。
くねくねした道からはみ出さないよう歩くのも、ゲームの一環なのである。
父が出かける前に「寄り道せず、道なりに西の町へ進むんだ。」と言ってはいるが、聞き逃すときつい。
また、戦闘中に回復アイテムを使っていなかったケースも判明。
初期クエストで手に入る「にんじん」はスタミナ2しか回復しないのだが、敵の攻撃ではスタミナがそれ以上に削れるので「使っても意味ない」と判断したようだ。

そこで、取り急ぎ以下の対策を行った。

注意書き

▲ 注意書きを追加

・父のセリフをより分かりやすく
・村を出たところに、注意書きの看板を緊急設置!
・「にんじん」の説明文の「スタミナを2回復」の部分を、「スタミナをわずかに回復するが、換金したほうがいいかも」的な内容に変更

ちなみに、
「ボス戦以外では、戦闘中回復しないでしょ?」
と言われたのだが、いやいや、そんなことありますかね?
「常識」って人によって違うので大変。

ともあれ、この経験で、UI/UXはもっと注意しないと、と反省させられた。

そういった目線であらためて考えてみるとユーザ導線がイケていないのでは……という気がしてきた。
ゲームの初めのほうで、ヒロイン3人の中から1人を選ぶ。
主人公の家から向かって左、西側が村からフィールドへの出口。
幼馴染のマアナはすぐ西隣に住んでいるし、初期イベントで絡むので分かりやすいが、あと二人のヒロインは北と東の建物に入らないと出会えない。
回復用の食べ物を調達するには、東側の農園まで行く必要がある。

今は忙しい時代なので、私のように、「村は隅々まで回り、NPCには全員漏れなく話しかけたい!」というプレイヤーばかりではないのだ。
最短距離で話を進めようとすると、他のヒロインの存在に気づかない、食べ物販売所にも気づかない、なんてことも普通に起こりそうだ。
ということで、ヒロイン3人の近くをくまなく巡れるように、初期クエストを組み直した。

だんだん不安になってきたので、序盤で妖精と会話した際に「旅のヒント集」をもらえるようにし、攻略のヒントを参照できる機能もつけた。

旅のヒント集

▲ ゲーム内のアイテムから開ける「旅のヒント集」

また、「インディゲームWEBオンリー」にて、レベルアップまでの残経験値を表示してほしい、という声をいただいた。
次のレベルアップの経験値は表示していたはずだが……?
と、よく見ると、今回のα版では最高レベルがLV8(経験値++)、経験値上限が1000になっていた(LV9相当)。
「経験値は溜まっていくのに、次のレベルアップ指標が表示されないし、いつまでたっても次のレベルに上がらない!」というストレスフルな状況になっていた。
取り急ぎレベル上限をLV9に訂正した。
自分のテストプレイではLV7で中ボスを倒してしまっており、念のためLV8(経験値600)に上がるところのテストだけして、満足してしまっていた。 自分でプレイすると、マップやパズルがすぐ解けてしまうため、なかなかレベルが上がりにくかった。

また、「プレイヤーが端にいかないとスクロールが始まるので遊びにくい」という意見もいただき、残り2ブロックでなく、3ブロックからスクロールが始まるように調整した。

向きを変えただけで敵とエンカウントしてしまうのが今一つという意見もいただいた(ごもっとも!)
これは次のブロックに進めるか確認する処理の部分で、エンカウント判定していたためだった。
そもそも向きを変えた瞬間に一歩進んでしまう仕様が自分でもイケていないと思っていたので、
・カーソルキーを押した際、向きが前と違う場合は、向きだけ変える(タイマー初期化!)
・指定時間を過ぎるまでは押しっぱなしでも動かさない
・指定時間を過ぎたらブロック移動し始める
・移動が終わった瞬間にエンカウント判定
というように調整した。

自分でもうすうす気になっている部分、
レベルや難易度調整については、自分だとなかなか分かりづらいので、複数の方の意見をいただけると本当にありがたい。
体験版では今のところ上限LV9だが、ベータ版では今のところ上限LV20ぐらいにしようと考えている。


どうにか中ボスまでのα版が形になった。
ここからラスボス、エンディングやおまけ要素まで、しっかり作りこんでいきたいと思う!

苦情・感想・アイディア、なんでも受け付けます。
 →ゲームα版はこちら
まだまだブラッシュアップしていきたいので、皆さまのプレー協力、ぜひぜひお願いします!