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

第26章:ギフトをあげてキャラの好感度をあげよう

キャラごとの好感度を実装

城作りと並行して、プレゼントでキャラの好感度をアップしていく機能を作った。
ヒロインやキャラクタたちの個性や背景を表せる大切な部分。
せっかくだから、「気に入ったものをあげたら固定のセリフ」などではなく、アイテムごとにセリフを変えられるようにしたい。 ChatGPTに現行のアイテムマネージャを渡して、相談してみた。
ユーザーアイコン
アイテムをNPCにプレゼントするgiftofferingを作ろうとしてる。
NPCごとに、ギフト配列を設けて、セリフが変更できるようにしたい。
NPCによってはギフトによって、好感度が上がるようにしたい。
AIアイコン
1) NPCごとの「好み(giftTaste)」を定義
まずは軽量に、npcGiftTable を1個作るのが気持ちいい。
 
// NPCごとのギフト反応テーブル
const npcGiftTable = {
// 例:ラファエル(仕立て屋)
rafael: {
name: "ラファエル",
affinityKey: "rafael",
gifts: {
"上質なワイン": {
accept: true,
affinity: +2,
lines: [
"「ほう……香りがよい。」",
"「仕事終わりに頂こう。」"
],
},
"激辛スパイス": {
accept: false,
affinity: 0,
lines: [
"「……私の舌は、繊細でね。」",
"「気持ちだけ受け取っておこう。」"
],
},
},
default: {
accept: true,
affinity: 0,
lines: ["「受け取っておこう。」"],
}
}
};

キャラのセリフ案まで入れてくれた。
アイテム一覧から上質なワインと激辛スパイスをチョイスする辺り、センスがいい。
「……私の舌は、繊細でね」
っていうのも、キャラを踏まえたふさわしいいいセリフだ。
長旅の相棒ならではというか、
「ラファエルはカイゼル髭が似合うお洒落な人物」
なんてメモリに( ..)φメモメモメモしていたものね。

まあ、ラファエルにプレゼントを渡す予定はないけどね……!

AIアイコン
2) 好感度を保存する箱(なければ追加)
 
// 例:playerStatus.affinity = { rafael: 0, sylphi: 0, ... }
playerStatus.affinity ??= {};

function addAffinity(key, delta) {
if (!key || !delta) return;
playerStatus.affinity[key] ??= 0;
playerStatus.affinity[key] += delta;
}
AIアイコン
3) giftOffering を「NPC判定込み」にする(ここが本体)
 
function giftOffering(recipientId){
const npc = npcGiftTable[recipientId];
if (!npc) {
runScript({ "1": { type: "message", text: "渡す相手が見つからない。", next: "2" }, "2": { type: "end" } }, "1");
return;
}

chooseItemForEvent(
(name, def) => def?.tags?.includes("gift"),
(itemName) => {
const rule = npc.gifts?.[itemName] ?? npc.default ?? npc.rejectDefault ?? { accept: true, affinity: 0, lines: ["「ありがとう。」"] };

// ここで「受け取る/受け取らない」を制御できる
// 受け取る場合だけ消費したいなら removeItem をここへ移動
if (rule.accept) {
removeItem(itemName);
}

// 好感度
if (npc.affinityKey && rule.affinity) {
addAffinity(npc.affinityKey, rule.affinity);
}

// メッセージ組み立て
const lines = [
`${playerName}は${npc.name}に${itemName}を渡した。`,
...(rule.lines || [])
];

// ちょい演出(好感度変化がある時だけ表示したい場合)
if (rule.affinity) {
const sign = rule.affinity > 0 ? "+" : "";
lines.push(`(好感度 ${sign}${rule.affinity})`);
}

runScript({
"1": { type: "message", text: lines.join("\n"), next: "2" },
"2": { type: "end" }
}, "1");
},
`${npc.name}に渡すプレゼントを選んでください。`
);
}

affinityKeyにidがあるので、日本語名は不要だ。ということで、nameは削った。
また、アイテム個別ですべて設定していくのはさすがにしんどいので、配列で設定できるようにし、タグもつけて単位でも設定できるようにした。
さらに、クエスト状態によって分岐できるようにもした。

(いつも通り)トラブル発生

まあ、これで一発OK……となるはずもない。
シナリオに組み込もうとすると問題発生。
ギフトを渡しても、断られても、その後のイベント処理が分岐できない。
ギフトを渡して喜ばれたら、お礼にお返しがもらえるとか、特別なイベントが始まる、みたいなケースも実装したいところだ。

ユーザーアイコン

ギフトはうまく回るようになったけど、今のイベントシステムのカスタムファンクションって、 ギフトを渡しても渡せなくても同じように先に進んじゃうんだよね。
呼び出す時に分岐があると指定したら、戻り値がtrue/falseでcurrentEventIdを分けられるようにしたいな。

AIアイコン

ここ、めっちゃ気持ちいい拡張の仕方がある。結論:
• custom の戻り値(または Promise の解決値)を true/false として扱う
• cmd.then / cmd.else(または nextTrue/nextFalse)で分岐先を指定する
• 指定がない場合は従来どおり next に進む(後方互換)

AIアイコン
currentStep を async function currentStep(){...} にして、
} else if (cmd.type === "custom") {
currentEventId = cmd.next;
const ret = cmd.func?.();
if (ret && typeof ret.then === "function")
await ret;
currentStep();
}

毎度おなじみの鬼門、同期/非同期問題がここでも出てきた。
トラブルの匂いがぷんぷんだ。

ユーザーアイコン

おっとそうか。
今まで色々なfuncを使ってきたから、すべてをPromiseで動くかどうか判定し直す必要があるかも。

AIアイコン

イベント処理が async 前提で書けるなら、custom 部分だけこうする:
} else if (cmd.type === "custom") {
if (typeof cmd.func === "function") {
// 既存互換:分岐指定が無いなら、従来通り next を先に入れて即実行
if (!cmd.then && !cmd.else) {
currentEventId = cmd.next;
cmd.func();
return;
} // ★ 分岐指定がある場合だけ:戻り値(または Promise)を評価
const ret = cmd.func();
const val = (ret && typeof ret.then === "function") ? await ret : ret;
const ok = !!val;
currentEventId = ok ? (cmd.then ?? cmd.next) : (cmd.else ?? cmd.next); return; } }

待て待て、なんだか急に複雑になったゾ。
目がちかちかしてくるので聞いてみた。

ユーザーアイコン

currentStepのfunc()呼び出し側のところなんだけど、
さっき書いてくれたこれってなんか複雑なことしてるよね?
retはtrue/falseだけだから、もっとシンプルになるよね?
===
const ret = cmd.func();
const val = (ret && typeof ret.then === "function") ? await ret : ret; const ok = !!val;
currentEventId = ok ? (cmd.then ?? cmd.next) : (cmd.else ?? cmd.next);
===

AIアイコン

うん、鋭い。
そのコードは **「Promiseでも同期でも両対応するための保険込み」**だから、
今の前提(ret は true / false しか返さない)なら もっとシンプルにしてOK。

custom func は「asyncで true / false を返す」契約に統一
これが 最小・明快・事故らない。

今回のfunctionであるgiftOffering、内部で別のスクリプト「runScript」を呼び出している。
そこで、runScriptの同期版であるrunScriptPromiseを作る必要も出てくる。

 
function runScriptPromise(script, startId) {
return new Promise(resolve => runScript(script, startId, resolve));
}

うーん、なんだかあっちの関数もこっち関数も非同期版と同期版が同居して地獄絵図。
増設に増設を重ねてプログラムを作ってくと、こんなふうになりがち。

そして「花を供える」機能で使っているchooseItemForEventも同期対応にする必要がある。

 
async function chooseItemForEvent(filterFn, message = "アイテムを選んでください。") {

今回chooseItemForEventはflowerOfferingとgiftOfferingでしか使っていなかったので、まだよかったが……

(ちょっと脱線)
「星のドラゴンクエスト」がサービス終了してしまった時のことを思い出す。
あれって要素が詰まりに詰まっていて、「ここまでやれるの?!」って驚愕したものなあ。
増設に増設を重ねて、メンテしきれなくなったのもうなずける。

しかし、思い返すと、実によくできたゲームだった。
のっけから、星が「グーグルアース」みたいにぐるぐるっと回転できてワクワク感爆上がり!
曲もいいし、バトルシステムもいいし、メインシナリオもよさげだし、ドラクエの過去作巡りもできる。
装備着せ替えも、オートマッチの協力バトルも楽しい。
ただ、ソシャゲであることが災いして、ストーリー進行が遅すぎるのが個人的には辛く、途中で手が止まってしまってた。
本当はサ終前に最後までシナリオ進めたかったなあ。

話はこれだけじゃ終わらない。

イベントの多重呼び出しの際に、最初にnextをcurrentIdとして保存しているから、then/elseに飛べないね・・・
functionで行き先を変えるには、PUSH/POPの仕組みを変えないとダメだね。

AIアイコン

うん、そこが“本丸”だね。
currentEventId = cmd.next;
cmd.func();
これを こうに変える。
const ok = await cmd.func();
PUSH/POPはそのままで、custom の “進行確定タイミング” を後ろにずらすだけで済む。
今まで next を先に入れてた理由は
「runScript が stack に積むとき、戻り位置を失わないため」だと思うけど、
その問題は “戻り位置を積むタイミング” を runScript 側に任せるのが正解。

いやいや、これは怪しい。
イベントスクリプト内で別のイベントスクリプトを呼び出す場合、runScriptの中で、
①次のcurrentStepを保存(PUSH)
②スクリプトを実行
③"end"で終わったところで、前のcurrentStepを取り出す(POP)
④前のスクリプトを実行
という流れになっている。
だから①の前にcurrentStep=nextを入れておかないと、前のIDが保存されてしまう。

ユーザーアイコン

いや、runScriptのほうを直さないといけないんじゃない?

AIアイコン

結論から言うと、その runScript() 自体は むしろ正しい寄りで、
「then/else に飛べない」問題の原因は runScript じゃなくて、custom を呼ぶ側が先に currentEventId = cmd.next を入れちゃってることだと思う。

ChatGPTが言い張るが、ここで負けてはダメ。

LLMはロジカルじゃない「勘ぴゅーた」(自説)。
いい答えをズバッと出すが、外す時は盛大に外す。
考えてみると、正解/不正解のはっきりするプログラムは、LLMとの協創にかなり向いているのではないだろうか。
なんたって、動かないものは動かないのだ。

ユーザーアイコン

currentEventIdを入れなかったらPOPされた時にひとつ前のイベントに戻ってしまうだけでは?

焦らず騒がず冷静に指摘すべし。
賢いLLMには、これぐらいの一言で伝わる。
ChatGPTは29秒考えこんだ後、納得した。

AIアイコン

うん、その懸念は100%当たり。

じゃあ「then/else を使いたい」場合はどうする?
やり方は2つあるけど、いちばん手堅いのはこれ。
✅ next を「分岐用の行」にしておく(then/else は “次の if” が担当)
つまり、custom 自体は 結果をどこかに保存して、次の行で if 分岐する。
もう一個の方法:runScript に「戻り先ID」を渡せるようにする

「custom 自体は 結果をどこかに保存」ってのが確かに一番手堅い。
次の行をif分岐にするなら、nextはもういじる必要もない。

giftOffering内でせっかくイベントスクリプトを使っているので、渡した結果の共通文言や効果音などはここで処理するのが便利。
この辺りは、ChatGPTにいちいち説明するより自分で書いたほうが早い。

あまり美しくないがいったんグローバルで結果の値を持たせ、giftOfferingはこんな感じで決着した。

 
    ===
let giftok;
async function giftOffering(tag = "gift",recipient,fraq){
const npc = npcGiftTable[recipient];
giftok = false;
if (!npc) {
await endCurrentScript();
return;
}
const itemName = await chooseItemForEvent((name, def) => def?.tags?.includes(tag),"プレゼントを選んでください。");
if(!itemName){
await endCurrentScript();
return;
}
const rule = resolveGiftRule(npc, itemName);
if (npc.affinityKey && rule.affinity) addAffinity(npc.affinityKey, rule.affinity);
giftok = rule.accept;
await runScriptPromise({
1: { type: "if", check: () => rule.accept, then: 2 , else: 3},
2: { type: "loseItem", itemname: ()=>itemName, text: ()=>`${itemName}を手渡した。`, next: 4},
3: { type: "message", text: "受け取ってもらえなかった。" },
4: { type: "message", text: () => rule.lines ,fraq: fraq},
5: { type: "if", check: () => rule.affinity > 1, then: "excellent" , else: 6},
6: { type: "if", check: () => rule.affinity > 0, then: "good" , else: 7},
7: { type: "if", check: () => rule.affinity < 0, then: "bad" , else: "end"},
excellent: { type: "sound", sound: "statusup", repeat: 1, wait: false, next: 8},
8: { type: "message", text: ()=>`とても喜んでもらえたようだ・・・! 好感度+${rule.affinity}`, next: "end" },
good: { type: "sound", sound: "statusup", repeat: 1, wait: false, next: 9},
9: { type: "message", text: ()=>`喜んでもらえたようだ 好感度+${rule.affinity}`, next: "end" },
bad: { type: "message", text: ()=>`好感度が下がったようだ・・・ 好感度${rule.affinity}`, next: "end" },
"end": { type: "end" }
}, "1");
return;
}

function addAffinity(key, delta) {
if (!key || !delta) return;
playerStatus.affinity[key] ??= 0;
playerStatus.affinity[key] += delta;
}
好感度を上げるシーン

やっとめでたく好感度パラメータが調整できるようになった!
え? 好感度あげたら何が起きるのかって?
それはこれから考えるってことで……


ふいぃぃ。城のクエストも進めねばなりませんね。忙しい忙しい。