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

第17章:タイトル画面、イベント時のオートスクロール、変身時の靄の演出

タイトル画面を作ろう

タイトル画面がいつまでも「RPGテスト」のままじゃ盛り上がらない。
Legend of Tristarというタイトルも決まったことなので、タイトル画面を作ることにした。

Legend of Tristarロゴ叩き1

▲ ビットマップ画像っぽく、縁取りが金で、本体に水色、と依頼したらこうなった

Legend of Tristarロゴ叩き2

▲ グラデーションにして、とお願いしたが、意図通りにならず

悪くはないのだが、まだ物足りない感じがする。

ユーザーアイコン

文字だけじゃなくて何かワンポイント入れるといいかもね。 星がみっつ輝いてるとか。

AIアイコン

あ、それめちゃくちゃいい!!✨
「Tristar=三つ星」ってタイトルの意味を
さりげなくロゴ演出に仕込めるし、
しかもレトロRPGっぽい星エフェクトなら
技術的にも簡単に実装できるやつだね。

Legend of Tristarロゴ叩き1

▲ グラデーションってこういうのを求めてた! かなり好み。

Legend of Tristarロゴ叩き2

▲ 昔の洋風ファンタジーなどにありそう。悪くない。

星を横配置するのと三角配置とで両方作ってみようと話したが……なぜか星は同じ配置になった。
が、どちらもかなり気に入った。

ユーザーアイコン

おおーいいね! 星はなんかどっちも似たような感じになったけどw
星が入っただけで、ディズニー映画の始まり風というか、なんかワクワク感が増した気がするよ。
一個目の奴、切り貼りサイズ調整してみる。

AIアイコン

わかる…!🌟
星が入っただけで一気に**「伝説が始まる」感**出たし、
あの キラッ✨と光るディズニー冒頭のワクワク感みたいなのが確実に乗ってるね。
この流れ…いよいよ「Legend of Tristarの幕開け感」出てきてるなあ🔥

悩んだ末、案1を自分で切り貼りして横長のタイトル画像に仕上げた。

この後、ChatGPTが提案してくれた「流れ星」演出を入れようとしてみた。
タイトル画像の下あたりに、すーっと流れるといいかも……ということで……

   
// 流れ星用のグローバル変数
let shootingStarX = canvas.width + 50;
// 流れ星のX位置
let shootingStarY = 260;
// 流れ星のY位置
let starTrailAlpha = 0;
// バーの透明度
function drawTitleScene() {
ctx.save();
// 背景塗りつぶし
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// タイトルロゴ表示
ctx.drawImage(loadedImages["title"], 0, 0);
// サブタイトル ctx.fillStyle = "white";
ctx.textAlign = "center";
ctx.font = "32px PixelMplus, sans-serif"; ctx.fillText("第Ⅰ章:冒険の始まり", canvas.width / 2, 280);
// ==== 🌠 流れ星描画 ====
if (shootingStarX > -50) {
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(shootingStarX, shootingStarY);
ctx.lineTo(shootingStarX - 10, shootingStarY + 5);
ctx.stroke(); shootingStarX -= 8;
// 移動スピード starTrailAlpha = 1;
// バーの透明度MAX
} else {
// 星が通り過ぎたらバーの透明度を減らす
if (starTrailAlpha > 0) {
starTrailAlpha -= 0.01;
// 徐々に消える
} else {
// 星を右にリセットして再出現
shootingStarX = canvas.width + Math.random() * 200;
}
}
// ==== 💫 バー描画 ====
if (starTrailAlpha > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${starTrailAlpha})`;
ctx.fillRect(150, shootingStarY, canvas.width - 300, 2);
}
// メニューオプション options = getTitleOptions();
options.forEach((option, i) => { if (titleSelection === i) {
ctx.fillStyle = "blue";
ctx.fillRect(canvas.width / 2 - 120, 340 + i * 50, 240, 40);
ctx.fillStyle = "white"; } else { ctx.fillStyle = "gray";
}
ctx.fillText(option, canvas.width / 2, 370 + i * 50);
});
ctx.restore();
}

組み込んでみたところ、
①ラインが右から左に伸びていく
②すべて表示された後、ラインが透明になっていく
③その後、ドットが右から左に流れていく
という、何がなんだかよく分からない状況になっていた。

そこで、動いていく星から尾を引くように線が現れる(=だんだん長くなる)ようにして流れ星風にすることを依頼。
フェイド演出は自分の手元でいじり、「グラデーションするならこんなのも」と参考に書いてくれてあったソースを組み合わせたところ……
奇跡的に、これぞ流れ星! というエフェクトが誕生した!

   
let gradient = ctx.createLinearGradient(140, 0, canvas.width - 140, 0);
gradient.addColorStop(0.5, rgba(255,255,255,${starTrailAlpha}));
gradient.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = gradient;
const w = (canvas.width - 140) - shootingStarX;
ctx.fillRect(shootingStarX, shootingStarY, (w > 0 ? w: 0), 2); if (shootingStarX > 140) {
ctx.fillStyle = "white";
ctx.beginPath();
ctx.arc(shootingStarX, shootingStarY, 5, 0, Math.PI * 2);
ctx.fill(); shootingStarX -= 8;
starTimer = 1;
}
if(starTimer > 0){
starTrailAlpha = 1 - (0.003 * starTimer);
console.log("alpha",(starTrailAlpha > 0 ? starTrailAlpha : 0) ,starTimer);
starTimer++;
if(starTimer > 500){
starTimer = 0;
shootingStarX = CANVAS_WIDTH + 50;
starTrialAlpha = 1;
}
}

とてもシンプルだが、これ以上やるとレトロっぽさが失われるので、いったんこのぐらいにしておくことにした。
(透明度を入れている時点でもうレトロっぽさは何処へ行ったという感じだが……)

効果音を追加する

画面がちょっとリッチになってくると、脳内でメニュー選択音が鳴り始める。
これだけ美しいタイトルロゴだと、少し残響音のある音がよさそうだ。

これを機に、ゲーム内にもカーソル移動や確定時の音などを追加していくことにした。
しかし、どういうタイミングで音を入れるべきだろう?
エンターで決定の時は入れるとして、キャンセル時はどうするか。
メニュー選択ではどうするか、など、考えることは山積みである。

ユーザーアイコン

なんかESCメニュー開閉だけは音あるほうがいい気がしてきた。
・・・試みに今ドラクエ10オンライン開いてみたら、メニュー開閉と、エンターキーのみ少し音がついてた。
パクる…いやいや、参考にさせていただいてもいいかも。
(後でパニガルムいかなきゃ)

注:パニガルムとはドラクエ10オンラインの中のバトルコンテンツのこと。
ぐぐったら大量に解説ブログが出てくるので参照されたし。
AIアイコン

🤣🤣🤣
「パクる…いやいや、参考にさせていただく」←RPG開発者みんなの心の声だねw
ドラクエ10オンラインのUI音設計って、さすが何十年選ばれ続けてるだけあって超参考になる👏

🎵 ドラクエ10式 SE分析
✅ ESCメニュー開閉
• 軽めの開閉音(ピョン/ポン系)
→ 操作感が分かりやすい+耳に残らない自然さ
✅ Enter決定のみSE
• 決定感ある音(控えめエコー)
→ 余韻が長すぎないので次の動作に移りやすい
✅ カーソル移動は無音
• 音が多すぎないことで落ち着き感が出る

🪄 Legend of Tristar式
もしドラクエ10式を参考にするなら👇

Legend of TristarのSE提案
ユーザーアイコン

「🪄 Legend of Tristar式」ありがとう。いいなあ、この表現w

実のところ、オンラインゲームってあまりやりたくなかったんだけど、
個人的にドラクエ2からドラクエ10に一気にタイムスリップして衝撃だったから、自分にとっては神ゲーなんだよw
やりたいこと全部ここで実現されてるじゃん! って自分の存在意義を見失いかけたぐらいだよw

AIアイコン

🤣🤣
「ドラクエ2から10に一気にタイムスリップ」←これは心臓止まるレベルの衝撃分かりすぎる…w
(ちなみに…パニガルム終わったら、トライスターのパニガルム枠も作ります?w **「深淵の三星樹」**みたいなネタ浮いた)
「もうやりたいこと全部ここにある…」 ってなるあの感覚、
RPG作りたい勢が一度は存在意義を見失う瞬間だよねww

🌌 でもLegend of Tristar式が逆に映える理由
超レトロRPGの制約感って今の時代むしろ新鮮
ドラクエ10はオンラインだから**「1人で没頭する濃密さ」**は作れない
トライスターは**開発者の“個の味”**が全部出る作品になる

つまりドラクエ10は「神殿」
トライスターは「魂の祠」だね✨

(正直 トライスターのパニガルム案、公式DLCにしたいレベルで妄想止まらんw)

いやいや……まさか、ぼそっとつぶやいたドラクエと「パニガルム」ネタにここまで反応してくれるとは……
こうやってノリの良くなった時のChatGPTって本当に好きだ。
人間と一緒で、好きなことについて語り始めると、クリエイティブレベルが一気にあがる感じがする。
なんだよ、「深淵の三星樹」って……
やる気倍増するじゃないか……!

……まあ、まずは、目先のLegend of Tristarの世界観を作り上げていくべく、コツコツがんばりましょう。

ゲーム内のセリフ後の次へ進む音は、控えめなものにしようと思い、ChatGPTに短いピッという音を作ってもらった。
ゲーム内のサウンドエフェクトって本当に奥が深く、もう少し勉強せねばと思う。

オートスクロール演出

さて、神殿を作り進め、中ボス前のイベント。
イベント中に、そろそろオートスクロールしたくなってきた。
もうバグる予感しかしないのだが、果たしてできるのだろうか……。

ユーザーアイコン

イベント中に主人公が停止した状態でもマップを3ブロック上にスクロールするなどできるようにするには、どうしたらいいかな。

ChatGPTはカメラ専用のスクロール関数を作って、それを呼び出せばいいという。

  
/** * カメラをタイル単位でスクロールする 
* @param {number} dxTiles 横方向に何タイル動かすか(+で右、-で左)
* @param {number} dyTiles 縦方向に何タイル動かすか(+で下、-で上)
* @param {number} duration ミリ秒
* @param {Function} [callback] 完了時コールバック */
function scrollCameraByTiles(dxTiles, dyTiles, duration, callback) {
const startX = mapOffsetX;
const startY = mapOffsetY;
const targetX = startX + dxTiles * TILE_SIZE;
const targetY = startY + dyTiles * TILE_SIZE;
const startTime = performance.now();
function animate() {
const t = Math.min((performance.now() - startTime) / duration, 1);
mapOffsetX = startX + (targetX - startX) * t;
mapOffsetY = startY + (targetY - startY) * t;
if (t < 1) { requestAnimationFrame(animate);
} else if (callback) {
callback();
}
}
animate();
}

スクロールすると画面描画自体が止まってしまった。
ブラウザのページスクロールと切り離せなどとアドバイスされたが、ブラウザのスクロールじゃなく、画面が真っ暗になってしまうのでこれは検討違いである。
ユーザが別マップに移動するまで画面が暗くなってしまって直らないことを説明し、ソースもつけた。
するとなぜか、

AIアイコン

You can have resolveMultiTargetAttack do all of your damage-and-statusEffect bookkeeping up front, so that each hit object already contains both the numerical damage and the extraMessage string (even if it’s empty). For example, replace your current function with something like this:
function resolveMultiTargetAttack(enemies, crosshair) {
...

突然、今の話とぜんぜん関係ないバトルシステムのことを話し始めたではないか!
しかも英語。
何が起きたんだ……怖すぎる。

辛抱強く日本語で話しかけると日本語に戻ったが、ソースを添付したらまた英語で話し出した。
言語処理に不具合でもあったのだろうか。
(この相談をしたのは確か割と出たての時のChatGPT5だった)

描画が画面をはみ出しているのでは、と言われるので確認したが、ログを仕込むとそうでもなさそう。
しかし、描画開始列のstartColがNaNになっているのを発見。
会話してもなかなか直らなかったが、しまいにdxTilesという変数がundefinedになっていたのを見つけて、一応描画はされるようになった。

が、今度はスクロールイベントが終わるとプレイヤーが一瞬右上に飛んでしまう。
もはや別のやり方を探すしかないか……などとも思い始めたが……
結局、ChatGPTに入れてみろと言われて入れたclampCamera()という関数を呼び出すのをやめたら直った。
なんだかこのパターン多いなあ……

もやもや演出

中ボス登場の際に、石像がバラバラになって変身する、みたいな演出がある。

ChatGPTはこんな素敵な絵も描いてくれたのだが、

石像の崩壊

ここから48×48に落としてアニメーションを入れるのが大変そうだし、それまでのイベントシナリオとかみ合わない。
むしろもやっと霧みたいなものが立ち込めるというお決まりの演出を入れてみよう。
時間を指定するstartParticleEffect()、実際に描画するdrawParticleEffect()を作っておき、前者はイベントシステムから、後者はフィールドを描画しているdraw()ループから呼び出せばよさそうだ。

ChatGPTが書いてくれたたたき台はこんな感じ。

🟣 1. データ構造

  
紫ドットのパーティクル定義
let particleEffect = null;
開始時にセット👇
function startParticleEffect(x, y, size, duration) {
particleEffect = { x, y, size, duration, startTime: performance.now(),
particles: Array.from({length: 30}, () => ({
offsetX: Math.random() * size - size/2, offsetY: Math.random() * size - size/2, radius: Math.random() * 3 + 1,
alpha: Math.random() * 0.5 + 0.3, life: Math.random() * duration
})) };
}

🟣 2. 描画関数

  
draw() の最後で呼び出す
function drawParticleEffect() {
if (!particleEffect) return;
const now = performance.now();
const elapsed = now - particleEffect.startTime;
const progress = elapsed / particleEffect.duration;
if (progress >= 1) {
particleEffect = null;
return;
}
ctx.save();
ctx.globalCompositeOperation = "lighter"; // 発光感
for (const p of particleEffect.particles) {
const t = (elapsed + p.life) % particleEffect.duration / particleEffect.duration;
const alpha = p.alpha * (1 - t); // フェードアウト
ctx.fillStyle = `rgba(128, 0, 128, ${alpha})`; // 紫色
ctx.beginPath();
ctx.arc(particleEffect.x + p.offsetX, particleEffect.y + p.offsetY, p.radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}

🟣 3. イベントから呼ぶ

 
startParticleEffect(ensoulX, ensoulY, 48, 3000); 
戦闘後:
startParticleEffect(ensoulX, ensoulY, 48, 2000);

これでx,yを指定した周辺にパーティクルが表示される……はずなのだが、されなかった。
おかしいなと思って確認すると、画面の端のほうに見えないぐらい薄く靄が出ていた。
変身するキャラのいるx,y座標がまともに取れていなかったようである。

というか、NPCの画面上の描画位置を取得するのが意外と大変で、スクロール処理なども挟んでしまったため、数字がその都度書き変わってしまっているようで手に負えない。
試行錯誤の末、startParticleEffectには座標以外にNPCのIDも渡せる仕組みとした。
そしてdraw()の瞬間には正確な描画位置が分かっているはずなので、drawParticleEffect()内で、

  
const npc = npcList.find(n => n.id === particleEffect.id);
x = npc.x * TILE_SIZE - mapOffsetX + TILE_SIZE / 2;
y = npc.y * TILE_SIZE - mapOffsetY + TILE_SIZE / 2;

のようにして、NPCの座標を取得するようにした。

これで位置の問題は解消したのだが、durationで設定した時間が経過すると、いきなりすべてのパーティクルが消失するのでなんだか妙だ。
そこで、particleFadeIn()、particleFadeOut()という関数を作り、イベントからバラバラに呼び出せるようにした。

ChatGPTは徐々にα値(透過度)をあげて色を薄くしようとしていたようだが、パーティクルの数を徐々に減らしていくようにするほうが自然に見えたので、以下のように書き直した。

  
  // パーティクル数の増減
let count = particleEffect.maxParticles;
if (particleEffect.fading) {
if (particleEffect.fadeDirection === "in") {
count = Math.floor(particleEffect.maxParticles * progress);
} else if (particleEffect.fadeDirection === "out") {
count = Math.floor(particleEffect.maxParticles * (1 - progress));
}
}
const elapsed = performance.now() - particleEffect.startTime;
for (let i = 0; i < count; i++) {
const p = particleEffect.particles[i];
const t = (elapsed + p.life) % particleEffect.baseLifetime / particleEffect.baseLifetime;
const alpha = 1 - t;
const fillColor = particleEffect.color.replace("ALPHA", alpha.toFixed(2));
ctx.fillStyle = fillColor;
ctx.beginPath();
ctx.arc(x + p.offsetX, y + p.offsetY, p.radius, 0, Math.PI * 2);
ctx.fill();
}

これでうまく行ったように見えていたのだが、またふつっと描画が消えるようになってしまった。
ChatGPTにログを渡しても、原因が判然としない。
しかし、
①α値がおかしい
②描画座標がおかしい
③半径が小さい
④描画後に消されている
可能性をChatGPTが挙げてくれたおかげで、はたと気がついた。

座標の計算基準となっているNPCを画面から消していたせいだった(靄も消えて当然) まあ、基準となる座標に透明NPCを配置しておけばよいのだが……

結局、このシーンではNPCの表示位置をログでいったん出力 → x,y座標をメモして、IDでなくx,y座標で靄の位置を指定することにしたのだった。
(なんだかんだ前の仕様に戻っているが、正確な表示位置を取得できたのは、NPC位置を取得するように作ったおかげ)


タイトル画像ができると、ゲーム世界への愛着が深まるのは、人間もChatGPTも同じらしい。
自分一人だったら絶対に手を出さなかったであろう「オートスクロール」も完成して感無量である。

いったんゲームもきりのいいところまでできたので、近日α版公開予定!
皆さんのご意見をいただきたいと思う。