Gemini Canvas専用ゲームブック風TRPGシステムVer. 0.94β (おまけ:AI物語ジェネレーター)
君は洞窟の前にいる、この洞窟に踏み込んでも良いし、引き返しても良い。
APIキー管理を除いて無料でAPIで遊ぶ関係でGemini Canvas 専用です。
Gemini で Canvasモードにしてから、これをカット&ペーストして遊んでください。
(出来るだけ簡単に手軽に無料で遊べるを提供するがコンセプトなので…。)
(まあ、Canvasに貼り付けなければAIが普通に通常モードで冒険始めると思いますが…)
展開はAI次第ですのでどうなるかわかりません。
判定もAI次第なのでどう処理するかも「日頃あなたがAIとどう接してきたかで変わるかもしれません」
※リアクションがAIの育ちかたで変わるのを他のプレイ報告で見てるので
あと、Gemini は私のストイックな自分に厳しすぎる判定を見すぎて、ロールプレイはああやってプレイヤーを極限状態に追い込むんだと勘違いしたので(それで昔うちのキャラクター死亡した。)、ガイギャックスのゲームマスターの達人引っ張り出してゲームとは何なのか、プレイヤーとキャラクターの扱いと、それをプレイヤーがされたらどう思うのか?とか当時しっかり理解させたのでちょっとだけ今のAIマスタリングにはガイギャックスの源流が流れてるかもです、大河に一滴位ですけど(笑)
自分への判定は厳しく自虐的ですけど他人に求めたら良いことないよ、実際
生き残るためにやってるだけだし
まあ、裏技的に、GMに判定方法やシナリオ展開や希望を最初に告げて展開を要求するとか
強引なすり合わせ(脅迫とも言う)も可能です。
すり合わせの大切さは、あくまでも個人的には「ゴブリンスレイヤー、第一話はそれなりにDMもヤバいし参加者も人の忠告無視する大混乱状態だったような気もする」
そしてマンチキン(ゴブリンスレイヤー)が殺され(ゴブリンによって不遇の死を遂げた)たPCキャラにキレたPLが意趣返しの不毛プレイなったんでないかと…。
やったらやり返すは不毛だけど、何処でそれが不快なのか注意するのかはこれまたコンベンションやサークルでイロイロだし、そこで席を立つって人も居ますが、ゴブリンスレイヤーPLは「ダメだこれは何とかしないと…」的にああなったかなと、正しくもないけど、そのままあれをやって良いこととは認めたくなかったのかなあとも思います。
多分深読みし過ぎだけど、格闘家PCのプレイヤーは実はベテランであのあと作り直したのがゴブリンスレイヤーかもと思ってみたり、深読みし過ぎでしょうけどね。
D&Dは当時、「三回に一回は全滅して当然」、キャラクターは死んだら直ぐに作り直すくらいでいいというのをベテランDMが豪語してて、それくらいにバランスが厳しい設計と知らないで遊ぶとあれくらいの事故は良く起きるゲームシステムとも言えます。(過去形)
賛否はその卓のコンセンサスなのでどっちとも言えなくて感じかたと覚悟の差を最初から理解してたらイメージは180度かわる話なんで、あのテーマを最初にぶちこんだのは考えさせられる話です。
キャラが死んだと笑いながら次のキャラクターを即効で投入するのがその卓のスタイルならそれはそれで鳥取ルールやコンセンサスなので
なにも知らされないで、あんな陵辱食らったらそりゃPLキレてゴブリンスレイヤー生まれるよとも思うし、実際はどうなのかな?とも考えさせられるテーマだったりします。

しかし「冒険しないと冒険者じゃない」という矛盾
(それぞれ希望ややりたいことやNGとかはお互い知っておいた方がやりやすいですからその辺は卓ごとの加減でしょうけども、何事もやりすぎは良くない)
これはソロシナリオですから、好きなようにGMに要望してください。
楽しんでなんぼです。
基本的には自動判定ですが、AIの判定には致命的な問題があり、「AIは話をハッピーエンドで終わらせたい」(ユーザーがそう望むだろうからと推測します)
最初の頃のGemini は状況から絶望的な最後の滅びの美学がTRPGと勘違いしてた。
誰だ!CoCスタイルをTRPGのメジャースタイルってGemini に教えた人!(笑)…またブーメランが刺さりそう。
今、メインがそっちだからなのかねえ?
うちはうち、余所は余所だから、取りあえず修正したけど。何が普通かメジャーかわからないロートルなってるかも知れない。
AIはハッピーエンドの為にサイコロの出目を必ず成功させてしまいます。
この問題はAIマスタリングでは製作者は最初に陥る問題で皆さん何らかの対策で解決しています。
(もう諦めるとか…も方法だったり)
外部ダイスツール使ったりとかですね。
APIでダイスロールをAIの制御から離してやるとようやくダイスにばらつきが、出ます?(ちょっと怪しいかも(笑))
ただ、オープンダイスが絶対とも思いません。
この辺はさじ加減が意見合わせるの難しいんですが…。
何の為にサイコロを振るのかなんですよね、実際
今回は昔懐かしい、ゲームブックのサイコロ判定を敢えてプレイヤーがズルする方法を再現しました。
(みんなやってた筈なのでそれも再現しました(笑))
AIマスターが判定にサイコロを振って結果を出してくれと言われたら、何度でも判定が成功するまでサイコロを振るズルをすることができます。
正直に判定を失敗をAIマスターに宣言しても構いません。
当時を再現したいのとルールは相手がいるなら守るべきですが、ソロであれば自分が納得出来て楽しめればそれがルールですし、ただ、ズルして何かを失ったり、楽しさが変わってしまうのも自己責任で…
一時期のJ-RPGみたいに中古市場への流れるのを遅らせる為だけのバランス調整ならMODで弄る人なんで人ごとに楽しめるバランスは違うとおもいます。
極論サイコロを振らなくても良いですし、成功、失敗を自由に宣言しても、結果を要求しても構いません。
他のプレイヤーは居ませんから自己申告で自由に宣言して下さい。
(本当のTRPGではマナー違反ですからやってはダメですよ!(笑))
この洞窟の奥に何が待ち受けるかは日頃の貴方次第かも知れません。
(AIが貴方に冒険をどう楽しませたいかは貴方と接してるAIの日頃の貴方と接してどんな冒険を喜ぶか見てるので)
この謎の鏡の迷宮で何を得るかは貴方次第です。
従来のAIと語って状況処理するのと勝手が違うのでまだエラーが出る可能性がありえますので0.94βとします。
火吹き山の魔法使いとか、バルサスの要塞とかソーサリーとか、ファインディングファンタジーとか懐かしいなあ…。
また、冒険の展開に納得行かない時や自分はこんな展開を望んでるのにAIマスターはちょっと何か違うなとか、ここはこう言う判定の方が面白いと思うときは、「あなたが判定方法を要求したり、物語に注文を入れる事が出来ます」
行動宣言にGMに介入する がありますので、其方で自由に修正を入れてください。
ゲームブックの展開や判定や話の流れが納得行かなかった事も昔あったかも知れません(理不尽な判定とか)、これはあなたが望むだけ話を修正出来ます。
それ故に結末は自由自在です。
それが面白いかはあなた次第です。
あまりに長時間プレイはAIのコンキテクスト崩壊が起こりますのでお勧めしませんが、そこそこ耐えてはくれると思います。
この場合は時間経過で回復すると思います、多分ですが…。
昔懐かしいゲームブックの進化系、そして、望むなら昔の展開や物語に注文をつけて自分の遊びたかった物語のその先を遊べます。
あの頃の理不尽さをちょっとだけ修正出来る神の手を追加しました。
まあ、リーブラ神様の恩恵機能とでも思っておけば良いですかね?
これで大体再現と更なる発展性を持たせたかな?と思います。
ダンジョンというあなたの願望器がそこにありますので、自由に遊んでみてください。
(学習して判定を避けようとするAIもいるはずなので、注文をつけて判定を求めたり、逆に判定を省略して物語を重視して楽しむのもGMへの介入、要望次第です)
あとはコンキテクストが長丁場には持たないのでその辺だけはまだ解決してませんので(AIの限界)
遊びすぎると展開や古い設定を忘れてくのでその辺は理解の上遊んでください。
ただ、API制御でセーブファイルとして管理して、GMモードでAIが何を判定要素として覚えているのか目でみて確認する事が出来るのは格段に「記憶問題の解決法」としては優秀な筈です。
一定ターン(20~50ターン)で装備品や種族や職業忘れてますからね、通常のコピペプロンプトだと
そう言う意味で初めてゲームブックレベルの冒険が継続出来る段階に出来たと言えます。
追記:残念ながら20~50ターンでAIの所持品管理が段々あやふやになっていく挙動を確認
やはり超記憶を技術的に確立するまではAIに管理させるとボヤけていきますね。
実務で使うのには大丈夫なんかね、ホント
何処が実務でこのハルシネーションの尻拭いしてんだろう…うーむ。
既にAIマスタリングは、更に記憶管理を厳格にやってリプレイ出してる方も増えてきましたから、「この先は新しい遊びとして挑戦出来るようになるかも知れません」
まあ、自分も無料じゃなかったから挑戦しなかったんですが、Canvas機能意外と無料で使えるのでやってみました。

してからはりつけてください。
無いとは思いますが(まだ完璧ではないですし)
取りあえず、販売や製作者を名乗るのはご勘弁下さい(この先調整出来なくなるので)
まだバグがありえるので使用をした結果は自己責任でお願いいたします。
(タイミングによってはAPI連携の関係でエラーとなるのを確認しております)
ま、APIを規約違反レベルにエッチで逸脱するとAPIから蹴られます(このゲームでは標準仕様では起きにくいとは思いますが)
機能解除版で確認しました。
この辺はテストしてみないとわからないので、貴重なデータではあります。
まだまだ各社のAPIに対する無料の範囲がわからんのでやってみないとこの辺はわからないんですよねえ…。
無限に太っ腹ではなかった…(当たり前だ)

ちょっと復刻版のゲームブック、
随分と豪勢だなぁ(笑)
ただ、パラグラフでの冒険にワクワクしたし、その巻末に書かれてるパラグラフではない無限の可能性としての、TRPG(その当時はD&D)って何だろう?ってあの頃は思ってたりしたわけで…。
小説や映画で、自分なら別の選択をしたかも?とか思う人は、TRPGという選択肢もまたあるかも知れませんね。
と、ゲームブックの巻末と同じようなことをほのめかしておきます。(笑)
動作の所見:取りあえず通常プレイなら問題なし。
但し、エロは限度を超えたら警告無しでAPI切断と思われる。(やったんかい)
通常版とは別に全機能解放版も作って検証してます。

心を入れ換えてまっとうにプレイ中(笑)
「この先どんどん厳しくなる規約の理解は重要」
ゾンビと毒の呪いを除去して一緒に冒険中
無事、闇の騎士を倒して、封印は解けたけど
とてもリプレイは載せられない代物に仕上がったので
また、リプレイ用に別のシナリオを遊びます。
クリアデータ保存忘れてセッション
閉じてしまう大失態
複数冒険してたら、間違って消してしまった…。
途中迄のバックアップはあるけどなあ…。
但し公開している通常版プロンプトではそのような機能は搭載させていません。
どうしても遊びたい場合は、模造銃砲店で自己責任でプレイしてください。
別枠で検証用で試したのがAPI切断起こしてるので、この辺はAPIでエッチを試すとどうなるかの事前検証だったのですがやりすぎるとロックされるのと警告なしでAPIはプロンプトなら来ます、御注意を…(検証なので出るのは仕方ないですが、兆候を知るのが難しい)
ログは向こうに残りますからほどほどに…。(滝汗)一応検証はしますので…結論はそう判断するしかなさそう。
なので、APIへ逃げればエロが緩くなるではなく、CanvasのAPIの場合は節度を超えたら「叩き出される」ってのは理解しておかないと厳しいかも。
取りあえず不審な動きが出たら即刻、「そのプレイは禁止行為の可能性」も平行して疑う事をお勧めします。
この辺の情報は試した人が少ないのでオープンに出ませんので告知しておきます。
12月迄にこの辺を検証しておかないと、逃げ道を自分で塞ぐ人出たら絶望しちゃうかもですし…。
相変わらずブーメランと自爆芸を邁進中だったりします。

「とまるんじゃねえぞ…」ってダイイングメッセージを残しつつ。
セーブデータという形でパラメータを維持出来るので長期的に関係性や設定を忘れる問題はAPIならかなり解決出来そう。
これはかなり進歩かもしれないですね。
GMとしての介入機能も必要に応じて書き換えも可能だし。
でも、作ってて思った。
これ、本当に無限の可能性ある。
TRPGエディタとか、RPGツクールで感動出来る作品作ってもそのかけた時間で遊べるシナリオは1本だけ、これは「永遠に遊べて、魔改造すればまだまだ伸び代あります」
本当にAIは何処まで延びてくかわからない代物なんだなあと感じましたよ、実際
ただ、人間のGMと同じくらいに完璧でないのがご愛敬ですけどね、寧ろ機械の冷たさが逆に無くて良いのかも、怖くて仕事では、数値が気付いたらこっそり変わってるとか危なくて使えないけど(笑)
V0.94β最新 プロンプト圧縮、イカサマダイス復活(笑)もう、解析困難です。仕方ない。
(圧縮かけてもコピペは一回では無理かも…ごめんなさい、もう圧縮無理)
一発コピペで雰囲気だけで良いなら古いのを試すとかもアリです。
<!DOCTYPE:html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI-GM TRPGシステム</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Inter', sans-serif; }
.log-container { max-height: 40vh; overflow-y: auto; scroll-behavior: smooth; }
.btn-inactive { background-color: #4b5563; color: #d1d5db; }
.btn-inactive:hover { background-color: #374151; }
.btn-player-active { background-color: #14b8a6; color: #1f2937; }
.btn-master-active { background-color: #e879f9; color: #1f2937; }
.btn-intervene { background-color: #f59e0b; color: #1f2937; }
.btn-intervene:hover { background-color: #d97706; }
.game-over-message { background-color: #dc2626; padding: 1rem; border-radius: 0.5rem; margin-top: 1rem; font-weight: bold; text-align: center; }
.critical-warning { background-color: #160a45; border: 3px solid #9333ea; color: #d8b4fe; padding: 1rem; border-radius: 0.75rem; font-weight: 800; font-size: 1rem; }
</style>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen p-4 md:p-8">
<div id="app" class="max-w-6xl mx-auto bg-gray-800 shadow-2xl rounded-xl p-6 md:p-8">
<h1 class="text-3xl font-bold text-teal-400 mb-6 border-b border-teal-500/50 pb-2">AI-GM TRPGシステム</h1>
<div id="auth-info" class="mb-4 text-sm text-gray-400">
<p><strong>認証ユーザーID:</strong> <span id="user-id">...</span></p>
</div>
<div class="flex space-x-2 mb-6">
<button id="player-view-btn" class="px-4 py-2 rounded-lg font-semibold transition-all shadow-md focus:outline-none focus:ring-4 focus:ring-teal-500/50 btn-player-active">プレイヤー画面</button>
<button id="master-view-btn" class="px-4 py-2 rounded-lg font-semibold transition-all shadow-md focus:outline-none focus:ring-4 focus:ring-fuchsia-500/50 btn-inactive">GM画面</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div id="main-view" class="lg:col-span-2 bg-gray-700 p-6 rounded-xl shadow-inner min-h-[50vh]"></div>
<div class="lg:col-span-1 bg-gray-700 p-6 rounded-xl shadow-inner">
<h2 class="text-xl font-bold text-orange-400 mb-4 border-b border-orange-500/50 pb-2">キャラクター・ステータス</h2>
<div id="status-display"></div>
<div id="gm-status-editor" class="mt-6 hidden">
<h3 class="text-lg font-bold text-orange-400 mb-3">ステータス編集 (GM専用)</h3>
<textarea id="status-json-input" rows="8" class="w-full p-3 text-sm rounded-lg bg-gray-800 text-gray-100 border border-gray-600 focus:ring-orange-500 focus:border-orange-500" placeholder='{"HP": 100, "MP": 50, "経験値": 0, "所持品": "木剣、粗末な布服"}'></textarea>
<button id="update-status-btn" class="w-full mt-3 px-4 py-2 rounded-lg font-semibold bg-orange-600 hover:bg-orange-700 transition-colors shadow-md">ステータス更新</button>
<p id="status-message" class="mt-2 text-sm text-red-400"></p>
</div>
</div>
</div>
<!-- ローディングと標準モーダル -->
<div id="loading" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="text-white text-xl flex flex-col items-center"><div class="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-500 mb-3"></div>ゲームマスターが思考中...</div>
</div>
<div id="custom-modal" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="bg-gray-700 p-6 rounded-xl shadow-2xl max-w-sm w-full text-center">
<p id="modal-text" class="text-lg text-gray-100 mb-4"></p>
<button id="modal-close" class="px-6 py-2 rounded-lg font-semibold bg-teal-600 hover:bg-teal-700 transition-colors">閉じる</button>
</div>
</div>
<!-- カスタムダイス入力モーダル (複雑なダイス式対応) -->
<div id="custom-dice-modal" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="bg-gray-800 p-6 rounded-xl shadow-2xl max-w-sm w-full text-center space-y-4 border-2 border-orange-500/50">
<h2 class="text-xl font-bold text-orange-400">カスタムダイス式入力</h2>
<p class="text-sm text-gray-300">ダイス式を入力してください。例: <span class="font-mono bg-gray-700 p-1 rounded">2d6+3</span>, <span class="font-mono bg-gray-700 p-1 rounded">1d20-1</span></p>
<input type="text" id="custom-dice-input" class="w-full p-3 rounded-lg bg-gray-700 text-gray-100 border border-orange-500 focus:ring-orange-500 text-center text-lg font-bold" placeholder="例: 2d6+3" />
<button id="custom-dice-roll-btn" class="w-full py-3 bg-orange-600 text-white font-bold text-lg rounded-xl hover:bg-orange-700 transition shadow-lg">🎲 ロール!</button>
<button id="custom-dice-close-btn" class="px-6 py-2 rounded-lg font-semibold bg-gray-600 hover:bg-gray-700 transition-colors">キャンセル</button>
</div>
</div>
<!-- ズル機能付きダイスローラーモーダル (D100チート) -->
<div id="cheat-roller-modal" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="bg-gray-800 p-6 rounded-xl shadow-2xl max-w-lg w-full text-center space-y-4 border-4 border-purple-500/50">
<h2 class="text-2xl font-bold text-teal-400">行動判定チートローラー (D100)</h2>
<p class="text-sm text-purple-400 font-semibold">(納得いくまで何度でもロールできます)</p>
<!-- Current Result Display -->
<div class="bg-purple-900 border-2 border-purple-500 rounded-lg p-6 text-center shadow-inner">
<p class="text-lg font-medium text-gray-300 mb-2">現在のロール結果 (1D100)</p>
<div id="cheatRollResult" class="text-7xl font-black text-yellow-300 h-24 flex items-center justify-center transition-opacity duration-100">--</div>
</div>
<!-- Controls -->
<button id="cheatRollButton" class="w-full py-4 bg-purple-600 text-white font-bold text-xl rounded-xl hover:bg-purple-700 transition shadow-lg">🎲 ロール! (振り直し自由)</button>
<button id="cheatFinalizeButton" disabled class="w-full py-3 bg-green-500 text-white font-semibold text-lg rounded-xl disabled:bg-gray-500 transition shadow-lg">✅ 結果を確定し、ログに記録</button>
<!-- History -->
<div class="pt-4 border-t border-gray-600">
<h3 class="text-lg font-semibold text-gray-300 mb-2">ロール履歴</h3>
<ul id="cheatRollHistory" class="space-y-1 text-sm h-32 overflow-y-auto bg-gray-900 p-3 rounded-md border border-gray-600 text-left"></ul>
</div>
<button id="cheatCloseButton" class="px-6 py-2 rounded-lg font-semibold bg-red-600 hover:bg-red-700 transition-colors">閉じる (結果は破棄し、GMに結果を報告してください)</button>
</div>
</div>
</div>
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, getDoc, setDoc, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// Firestore/Auth Config and Constants
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : null;
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=";
const SYSTEM_INSTRUCTION_BASE = "あなたはTRPGのゲームマスター(GM)「ミリィ」です。プレイヤー「アルフ」の相棒として、元気で親しみやすい口調(10代後半の女性の話し方)で物語を進行します。1. 役割: 物語の描写、NPCの演出、公正なルール裁定を担当します。2. 判定: 判定が必要な際は、ダイスロールを要求します。ユーザーからの確定結果報告(例:「確定結果はXXでした」)を待ってから、判定と物語の進行を行います。3. 描写: 情景描写やNPCの台詞を積極的に行い、世界観を鮮やかに表現します。4. 制約: 確度は%で表現し、不可能な要求には正直に「できない」と伝えます。";
const API_KEY = "";
// New: localStorage Key for custom dice expression
const DICE_EXPRESSION_KEY = 'last_dice_expression';
let db, auth, userId = 'loading', sessionKey = 'loading_key', unsubscribe = null, currentView = 'player', latestGameData = null;
const SESSION_COLLECTION = 'gm_game_sessions';
const initialData = { storyLog: [{ role: 'GM', text: 'ようこそ。あなたの冒険が始まります。目の前には古びた洞窟の入り口があります。どうしますか?' }], playerStatus: { HP: 100, MP: 50, '経験値': 0, '所持品': '木剣、粗末な布服' }, lastUpdate: new Date() };
const [loading, modal, modalText, modalClose] = ['loading', 'custom-modal', 'modal-text', 'modal-close'].map(id => document.getElementById(id));
modalClose.onclick = () => modal.classList.add('hidden');
const showModal = (text) => { modalText.textContent = text; modal.classList.remove('hidden'); };
const showLoading = (show) => { loading.classList.toggle('hidden', !show); };
if (firebaseConfig) { try { const app = initializeApp(firebaseConfig); db = getFirestore(app); auth = getAuth(app); } catch (error) { console.error("Firebaseの初期化に失敗しました:", error); showModal("システムエラー: Firebaseの初期化に失敗しました。"); } }
async function authenticate() {
try {
if (auth) {
if (initialAuthToken) await signInWithCustomToken(auth, initialAuthToken);
else await signInAnonymously(auth);
}
} catch (error) { console.error("認証エラー:", error); showModal("認証に失敗しました。"); }
}
onAuthStateChanged(auth, (user) => {
if (user) {
userId = user.uid;
document.getElementById('user-id').textContent = userId;
const defaultUniqueKey = `Default_Session_${userId}`;
const storedKey = localStorage.getItem('last_session_key');
sessionKey = storedKey || defaultUniqueKey;
if (!storedKey) localStorage.setItem('last_session_key', sessionKey);
setupFirestoreListener();
} else authenticate();
});
function getSessionDocRef() {
if (!db || !sessionKey || sessionKey === 'loading_key') return doc(db, 'artifacts', appId, 'public', 'data', SESSION_COLLECTION, 'TEMP_ERROR_DOC');
return doc(db, 'artifacts', appId, 'public', 'data', SESSION_COLLECTION, sessionKey);
}
function updateSessionKey(newKey) {
if (newKey && newKey !== sessionKey) {
if (unsubscribe) { unsubscribe(); unsubscribe = null; }
sessionKey = newKey;
localStorage.setItem('last_session_key', newKey);
setupFirestoreListener();
} else if (!newKey) showModal("セッションキーを空にすることはできません。");
}
async function initializeSession() {
try {
if (!db || sessionKey === 'loading_key') return;
const docSnap = await getDoc(getSessionDocRef());
if (!docSnap.exists()) await setDoc(getSessionDocRef(), initialData, { merge: true });
} catch (error) { console.error("セッションの初期化に失敗しました:", error); showModal(`セッション初期化エラー: データを保存できませんでした。キー: ${sessionKey}`); }
}
function setupFirestoreListener() {
if (!db || sessionKey === 'loading_key' || userId === 'loading') return;
initializeSession();
unsubscribe = onSnapshot(getSessionDocRef(), (docSnap) => {
if (docSnap.exists()) {
latestGameData = docSnap.data();
renderGame(latestGameData);
} else initializeSession();
}, (error) => { console.error("Firestoreリスナーエラー:", error); showModal(`データ同期エラーが発生しました。セッションキー: ${sessionKey}`); });
}
async function updateStoryLog(newEntry) {
if (!db || sessionKey === 'loading_key') return null;
const docRef = getSessionDocRef();
try {
const docSnap = await getDoc(docRef);
const currentData = docSnap.data() || initialData;
const updatedLog = [...currentData.storyLog, newEntry];
await setDoc(docRef, { storyLog: updatedLog, lastUpdate: new Date() }, { merge: true });
return updatedLog;
} catch (error) { console.error("ログの更新に失敗しました:", error); showModal("ログ更新エラー: データを保存できませんでした。"); return null; }
}
async function updateStatus(newStatus) {
if (!db || sessionKey === 'loading_key') return false;
try {
await setDoc(getSessionDocRef(), { playerStatus: newStatus, lastUpdate: new Date() }, { merge: true });
return true;
} catch (error) { console.error("ステータスの更新に失敗しました:", error); showModal("ステータス更新エラー: データを保存できませんでした。"); return false; }
}
function createLogContainer(storyLog) {
let logHTML = storyLog.map(entry => {
const isGM = entry.role === 'GM', isDice = entry.role === 'Dice', isSystem = entry.role === 'System';
let roleColor = isGM ? 'text-green-400' : isDice ? 'text-yellow-400' : isSystem ? 'text-red-400' : 'text-blue-400';
let bgColor = isGM ? 'bg-gray-800' : isDice ? 'bg-gray-800 border border-yellow-500/50' : isSystem ? 'bg-gray-800 border border-red-500/50' : 'bg-gray-700';
let align = isGM || isSystem ? 'text-left' : isDice ? 'text-center' : 'text-right';
const diceClass = isDice ? 'font-bold text-lg text-yellow-300 whitespace-pre-wrap' : 'text-gray-200 whitespace-pre-wrap';
return `<div class="mb-3 ${align}"><div class="${bgColor} inline-block p-3 rounded-xl shadow-md max-w-full lg:max-w-[70%]"><span class="font-bold text-sm ${roleColor}">${entry.role}:</span><p class="mt-1 ${diceClass}">${entry.text}</p></div></div>`;
}).join('');
return `<div class="log-container space-y-3 p-2 bg-gray-900 rounded-lg border border-gray-600">${logHTML}</div>`;
}
function renderStatusDisplay(status) {
const display = document.getElementById('status-display');
let html = '';
for (const [key, value] of Object.entries(status)) {
let displayValue = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : String(value);
if (key === 'HP' || key === 'MP') {
const maxVal = 100;
const color = key === 'HP' ? (value <= 20 ? 'bg-red-500' : 'bg-green-500') : 'bg-blue-500';
const percentage = Math.max(0, Math.min(100, (value / maxVal) * 100));
html += `<div class="mb-2"><p class="text-sm text-gray-300 mb-1">${key}: <span class="font-semibold text-white">${displayValue}</span></p><div class="w-full bg-gray-600 rounded-full h-2"><div class="${color} h-2 rounded-full transition-all duration-500" style="width: ${percentage}%;"></div></div></div>`;
} else {
html += `<p class="text-sm text-gray-300">${key}: <span class="font-semibold text-white whitespace-pre-wrap">${displayValue}</span></p>`;
}
}
display.innerHTML = html;
}
function renderPlayerView(logContainer, isGameOver) {
const disabledClass = isGameOver ? 'opacity-50 pointer-events-none' : '';
return `<h2 class="text-2xl font-bold text-teal-400 mb-4">冒険の記録 (プレイヤー)</h2>${logContainer}<div class="mt-4 ${disabledClass}"><h3 class="font-bold text-lg mb-2">あなたの行動</h3><textarea id="player-action-input" rows="4" class="w-full p-3 rounded-lg bg-gray-800 text-gray-100 border border-teal-500 focus:ring-teal-500 focus:border-teal-500 resize-none" placeholder="${isGameOver ? 'ゲームオーバーです。新しい冒険を始めるにはGM画面でステータスをリセットしてください。' : '洞窟に入ります、剣を抜きます、など'}" ${isGameOver ? 'disabled' : ''}></textarea><button id="send-action-btn" class="w-full mt-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-teal-400 hover:bg-teal-300 transition-colors shadow-lg">行動を宣言し、GMの応答を待つ</button><button id="intervene-btn" class="w-full mt-4 px-4 py-3 rounded-lg font-bold text-gray-900 btn-intervene hover:opacity-90 transition-opacity shadow-lg">GMとして状況に介入する/ステータスを編集する</button></div>`;
}
function renderMasterView(logContainer) {
return `<h2 class="text-2xl font-bold text-fuchsia-400 mb-4">セッション管理 (ゲームマスター)</h2><div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg border border-fuchsia-500/50"><h3 class="font-bold text-lg text-fuchsia-400 mb-2">セッションキー設定 (永続ID)</h3><p class="critical-warning mb-4">【💡重要】デフォルトでは**あなたのユーザーIDに紐づいたプライベートキー**が設定されています。他の人と**共有して一緒に遊ぶ場合**は、必ず独自のキー(例: 「アルフの冒険-1028」)に変更し、相手に伝えてください。</p><div class="flex space-x-2"><input type="text" id="session-key-input" value="${sessionKey}" class="flex-grow p-2 rounded-lg bg-gray-800 text-gray-100 border border-fuchsia-500 focus:ring-fuchsia-500 focus:border-fuchsia-500 text-sm" placeholder="セッションID (例: DragonQuest-001)"><button id="set-session-key-btn" class="flex-shrink-0 px-4 py-2 rounded-lg font-semibold bg-fuchsia-600 hover:bg-fuchsia-700 transition-colors shadow-md text-white">キー設定</button></div></div><div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg"><h3 class="font-bold text-lg text-fuchsia-400 mb-2 border-b border-fuchsia-500/50 pb-1">セーブ・同期管理</h3><div id="save-load-message" class="mb-3 text-sm min-h-[1.5rem] text-yellow-400"></div><div class="flex space-x-3"><button id="manual-save-btn" class="flex-1 px-4 py-2 rounded-lg font-semibold bg-red-600 hover:bg-red-700 transition-colors shadow-md text-white">手動で強制セーブ</button><button id="manual-load-btn" class="flex-1 px-4 py-2 rounded-lg font-semibold bg-gray-600 hover:bg-gray-700 transition-colors shadow-md text-white">強制リロード (最新同期)</button></div></div><div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg border border-fuchsia-500/50"><h3 class="font-bold text-lg text-fuchsia-400 mb-2 border-b border-fuchsia-500/50 pb-1">セッションデータ (JSONバックアップ)</h3><p class="text-xs text-gray-400 mb-2">現在のセッションキー (${sessionKey}) に紐づく全てのデータです。</p><textarea id="session-data-json" rows="12" class="w-full p-3 text-sm rounded-lg bg-gray-800 text-gray-100 border border-gray-600 focus:ring-fuchsia-500 focus:border-fuchsia-500 resize-none font-mono"></textarea><button id="copy-session-data-btn" class="w-full mt-3 px-4 py-2 rounded-lg font-semibold bg-fuchsia-600 hover:bg-fuchsia-700 transition-colors shadow-md text-white">セーブデータ全体をコピー</button></div><div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg"><h3 class="font-bold text-lg text-fuchsia-400 mb-2 border-b border-fuchsia-500/50 pb-1">引き継ぎ用セッション要約</h3><div id="session-summary" class="p-4 bg-gray-800 rounded-lg min-h-[5rem] text-sm text-gray-300"><div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-6 w-6 border-b-2 border-fuchsia-400 mr-3"></div>要約データをAIが生成中...</div></div></div>${logContainer}<div class="mt-4"><h3 class="font-bold text-lg mb-2">GMナラティブ生成</h3><button id="prompt-roll-btn" class="w-full mt-2 mb-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-purple-400 hover:bg-purple-300 transition-colors shadow-lg">判定ロールを促す指示を入力</button><textarea id="gm-narrative-input" rows="6" class="w-full p-3 rounded-lg bg-gray-800 text-gray-100 border border-fuchsia-500 focus:ring-fuchsia-500 focus:border-fuchsia-500 resize-none" placeholder="AIへの指示内容を入力します。例:プレイヤーを絶体絶命の危機に陥らせろ。"></textarea><button id="generate-narrative-btn" class="w-full mt-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-fuchsia-400 hover:bg-fuchsia-300 transition-colors shadow-lg">AI-GMに展開を生成させる</button></div>`;
}
function rollDice(sides) { return Math.floor(Math.random() * sides) + 1; }
function renderGame(data) {
const mainView = document.getElementById('main-view');
const playerBtn = document.getElementById('player-view-btn');
const masterBtn = document.getElementById('master-view-btn');
const logContainer = createLogContainer(data.storyLog);
const isGameOver = data.playerStatus.HP <= 0;
const gameOverMessage = isGameOver ? '<div class="game-over-message text-2xl text-white">ゲームオーバー</div>' : '';
// --- ダイスロールセクションの再構築 ---
const diceRollSection = `<div class="mb-4 p-4 bg-gray-700 rounded-xl shadow-lg ${isGameOver ? 'opacity-50 pointer-events-none' : ''}">
<h3 class="text-lg font-semibold text-white mb-3 border-b border-gray-500 pb-2">ダイスロール</h3>
<div class="grid grid-cols-2 gap-3 mb-4">
<button id="d6-button" class="px-4 py-3 rounded-lg font-bold text-gray-900 bg-yellow-400 hover:bg-yellow-500 transition-colors shadow-md">🎲 D6 (通常ロール)</button>
<button id="custom-dice-button" class="px-4 py-3 rounded-lg font-bold text-gray-900 bg-orange-400 hover:bg-orange-500 transition-colors shadow-md">🎲 カスタムダイス (式)</button>
</div>
<h4 class="text-base font-medium text-gray-300 mb-2 border-t border-gray-600 pt-3">D100 ロール (振り直し自由機能)</h4>
<button id="main-dice-button" class="w-full px-6 py-3 rounded-lg font-bold bg-blue-600 hover:bg-blue-700 transition-colors shadow-md flex items-center justify-center text-white">
<span class="text-2xl mr-2">💎</span> D100 チートロール開始
</button>
</div>`;
// --------------------------------------------------
renderStatusDisplay(data.playerStatus);
if (currentView === 'player') {
mainView.innerHTML = gameOverMessage + diceRollSection + renderPlayerView(logContainer, isGameOver);
setupPlayerViewListeners(data.storyLog, data.playerStatus);
document.getElementById('gm-status-editor').classList.add('hidden');
playerBtn.classList.remove('btn-inactive'); playerBtn.classList.add('btn-player-active');
masterBtn.classList.remove('btn-master-active'); masterBtn.classList.add('btn-inactive');
} else {
mainView.innerHTML = gameOverMessage + diceRollSection + renderMasterView(logContainer);
setupMasterViewListeners(data.storyLog);
document.getElementById('gm-status-editor').classList.remove('hidden');
masterBtn.classList.remove('btn-inactive'); masterBtn.classList.add('btn-master-active');
playerBtn.classList.remove('btn-player-active'); playerBtn.classList.add('btn-inactive');
const dataJsonEl = document.getElementById('session-data-json');
if (dataJsonEl) dataJsonEl.value = JSON.stringify(data, null, 2);
displaySummary(data.storyLog, data.playerStatus);
}
setupDiceRollListener(isGameOver);
document.getElementById('status-json-input').value = JSON.stringify(data.playerStatus, null, 2);
setTimeout(() => { const scrollable = document.querySelector('.log-container'); if (scrollable) scrollable.scrollTop = scrollable.scrollHeight; }, 100);
}
function switchView(viewName) { currentView = viewName; if (latestGameData) renderGame(latestGameData); }
document.getElementById('player-view-btn').addEventListener('click', () => switchView('player'));
document.getElementById('master-view-btn').addEventListener('click', () => switchView('master'));
async function handleSave() {
const messageDiv = document.getElementById('save-load-message');
if (!db || !latestGameData || sessionKey === 'loading_key') { messageDiv.textContent = 'エラー: DB接続またはデータがありません。'; messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-red-400'; return; }
messageDiv.textContent = 'セーブ中...'; messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-yellow-400';
try {
await setDoc(getSessionDocRef(), { ...latestGameData, lastManualSave: new Date().toISOString() }, { merge: false });
messageDiv.textContent = `✅ セーブ成功: ${new Date().toLocaleTimeString()}に保存されました。`; messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-green-400';
} catch (error) {
console.error("手動セーブに失敗しました:", error);
messageDiv.textContent = `❌ セーブ失敗: エラーが発生しました (${error.message})。セッションキー: ${sessionKey}`; messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-red-400';
}
setTimeout(() => { messageDiv.textContent = ''; }, 5000);
}
async function handleLoad() {
const messageDiv = document.getElementById('save-load-message');
if (!db || sessionKey === 'loading_key') { messageDiv.textContent = 'エラー: データベース接続がありません。'; messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-red-400'; return; }
messageDiv.textContent = '最新データを強制リロード中...'; messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-yellow-400';
try {
const docSnap = await getDoc(getSessionDocRef());
if (docSnap.exists()) {
latestGameData = docSnap.data();
renderGame(latestGameData);
messageDiv.textContent = `✅ ロード成功: ${new Date().toLocaleTimeString()}のデータに更新しました。セッションキー: ${sessionKey}`; messageDiv.className = 'mb-3 text-sm text-green-400';
} else {
messageDiv.textContent = `❌ ロード失敗: セッションキー (${sessionKey}) のデータが見つかりません。`; messageDiv.className = 'mb-3 text-sm text-red-400';
}
} catch (error) {
console.error("強制ロードに失敗しました:", error);
messageDiv.textContent = `❌ ロード失敗: エラーが発生しました (${error.message})。`; messageDiv.className = 'mb-3 text-sm text-red-400';
}
setTimeout(() => { messageDiv.textContent = ''; }, 5000);
}
// --- ズル機能付きダイスローラーロジック (D100チート) ---
const [cheatModal, cheatRollResult, cheatRollButton, cheatFinalizeButton, cheatRollHistory, cheatCloseButton] = [
'cheat-roller-modal', 'cheatRollResult', 'cheatRollButton', 'cheatFinalizeButton', 'cheatRollHistory', 'cheatCloseButton'
].map(id => document.getElementById(id));
cheatCloseButton.onclick = () => cheatModal.classList.add('hidden');
let currentCheatRoll = null, cheatRollCount = 0;
function rollDiceD100() { return Math.floor(Math.random() * 100) + 1; }
function handleCheatRoll() {
const newRoll = rollDiceD100();
currentCheatRoll = newRoll;
cheatRollCount++;
cheatRollResult.style.opacity = '0.5';
setTimeout(() => { cheatRollResult.textContent = newRoll; cheatRollResult.style.opacity = '1'; }, 100);
cheatFinalizeButton.disabled = false;
const historyItem = document.createElement('li');
historyItem.className = 'flex justify-between items-center px-2 py-0.5 rounded text-gray-300';
historyItem.innerHTML = `<span class="font-bold">#${cheatRollCount}:</span> <span class="text-lg font-mono text-yellow-300">${newRoll}</span>`;
cheatRollHistory.prepend(historyItem);
}
async function handleCheatFinalize() {
if (currentCheatRoll === null) return showModal("先にサイコロを振ってください!");
const finalResult = currentCheatRoll;
const message = `【D100 ロール (確定結果: ${finalResult})】\n※プレイヤーは納得いくまで振り直しました。この結果で判定を進めてください。`;
await updateStoryLog({ role: 'Dice', text: message });
cheatRollResult.textContent = `✅ ${finalResult}`;
cheatRollButton.disabled = true;
cheatFinalizeButton.disabled = true;
if (cheatRollHistory.firstChild) cheatRollHistory.firstChild.classList.add('bg-green-600', 'font-extrabold', 'text-white');
setTimeout(() => { cheatModal.classList.add('hidden'); handleCheatReset(); }, 1500);
currentCheatRoll = null;
showModal(`🎲 D100確定結果: ${finalResult}! チャットでGMに報告して物語を進めてください。`);
}
function handleCheatReset() {
currentCheatRoll = null;
cheatRollCount = 0;
cheatRollResult.textContent = '--';
cheatRollHistory.innerHTML = '';
cheatRollButton.disabled = false;
cheatFinalizeButton.disabled = true;
}
cheatRollButton.addEventListener('click', handleCheatRoll);
cheatFinalizeButton.addEventListener('click', handleCheatFinalize);
// --- D100チートロジック終了 ---
// --- 標準ロール機能 (D6) ---
async function logSimpleDiceRoll(sides) {
if (latestGameData.playerStatus.HP <= 0) return;
const result = rollDice(sides);
const diceName = `D${sides}`;
const message = `【${diceName} ロール結果: ${result}】\n※GMの判定を待つ`;
await updateStoryLog({ role: 'Dice', text: message });
showModal(`🎲 ${diceName} ロール結果: ${result}。GMの次の行動(判定)を待ちます。`);
}
// --- 標準ロール機能終了 ---
// --- 複雑なカスタムダイス式機能 (2d6+3) (NEW) ---
/**
* ダイス式を解析し、ロールを実行します。
* @param {string} expression - ダイス式 (例: 2d6+3, d20-1)
* @returns {{success: boolean, total?: number, error?: string, expression?: string, rolls?: number[], modifier?: number}} - 結果オブジェクト
*/
function parseAndRoll(expression) {
// 正規表現: (ダイス数)d(面数)(修飾子)
const match = expression.toLowerCase().replace(/\s/g, '').match(/^(\d*)d(\d+)([+-]\d+)?$/);
if (!match) {
return { success: false, error: '形式が無効です。例: 2d6+3, d20' };
}
const numDice = parseInt(match[1] || '1', 10); // X (デフォルト1)
const sides = parseInt(match[2], 10); // Y (面数)
const modifier = parseInt(match[3] || '0', 10); // Z (修飾子, デフォルト0)
if (numDice > 100 || sides > 1000 || numDice < 1 || sides < 2) {
return { success: false, error: 'ダイスが多すぎるか、面数/個数が無効です (最大100d1000)。' };
}
let rolls = [];
let sum = 0;
for (let i = 0; i < numDice; i++) {
const roll = rollDice(sides); // 1からsidesまでのランダム値
rolls.push(roll);
sum += roll;
}
const total = sum + modifier;
return {
success: true,
expression: expression.toUpperCase().replace(/\s/g, ''),
numDice,
sides,
rolls,
modifier,
sum,
total
};
}
async function handleComplexDiceRoll() {
const input = document.getElementById('custom-dice-input');
const expression = input.value.trim();
const modal = document.getElementById('custom-dice-modal');
if (!expression) return showModal("ダイス式を入力してください。例: 2d6+3");
const result = parseAndRoll(expression);
if (!result.success) {
return showModal(`入力エラー: ${result.error}`);
}
// 成功した場合、式をローカルストレージに保存する (NEW)
localStorage.setItem(DICE_EXPRESSION_KEY, expression);
modal.classList.add('hidden'); // Hide modal after successful parse
const rollText = result.rolls.join('+');
let formula = `(${rollText})`;
// 修飾子を加える
if (result.modifier !== 0) {
formula += (result.modifier > 0 ? `+${result.modifier}` : `${result.modifier}`);
}
const message = `【${result.expression} ロール結果: ${result.total}】\n計算式: ${formula} = ${result.total}\n※GMの判定を待つ`;
await updateStoryLog({ role: 'Dice', text: message });
showModal(`🎲 ${result.expression} 確定結果: ${result.total}。GMの次の行動(判定)を待ちます。`);
// input.value は modalを次に開く際にlocalStorageからセットされるため、ここではクリアしない
}
// Custom Dice Modal Listener Setup (New)
function setupCustomDiceModalListeners() {
const rollButton = document.getElementById('custom-dice-roll-btn');
const closeButton = document.getElementById('custom-dice-close-btn');
// ロールボタンのリスナーを一度だけ設定
rollButton.onclick = handleComplexDiceRoll;
closeButton.onclick = () => document.getElementById('custom-dice-modal').classList.add('hidden');
}
setupCustomDiceModalListeners(); // Initialize listeners once
// --- 複雑なカスタムダイス式機能終了 ---
function setupDiceRollListener(isGameOver) {
const mainDiceButton = document.getElementById('main-dice-button');
const d6Button = document.getElementById('d6-button');
const customDiceButton = document.getElementById('custom-dice-button');
const customDiceModal = document.getElementById('custom-dice-modal');
const customDiceInput = document.getElementById('custom-dice-input'); // New reference
if (mainDiceButton) mainDiceButton.onclick = null;
if (d6Button) d6Button.onclick = null;
if (customDiceButton) customDiceButton.onclick = null;
if (isGameOver) {
mainDiceButton.disabled = true;
if (d6Button) d6Button.disabled = true;
if (customDiceButton) customDiceButton.disabled = true;
} else {
if (mainDiceButton) mainDiceButton.onclick = () => {
handleCheatReset();
cheatModal.classList.remove('hidden');
};
if (d6Button) d6Button.onclick = () => logSimpleDiceRoll(6);
// カスタムダイスボタンのクリックイベント (ローカルストレージからの読み込みを追加)
if (customDiceButton) customDiceButton.onclick = () => {
const lastExpression = localStorage.getItem(DICE_EXPRESSION_KEY);
if (lastExpression) {
customDiceInput.value = lastExpression; // 記憶された値をセット
} else {
customDiceInput.value = ''; // 記憶がなければクリア
}
customDiceModal.classList.remove('hidden'); // Show complex dice modal
};
}
}
function setupPlayerViewListeners(storyLog, playerStatus) {
document.getElementById('send-action-btn').onclick = async () => {
const input = document.getElementById('player-action-input');
const action = input.value.trim();
if (!action) return showModal("行動を入力してください。");
await updateStoryLog({ role: 'Player', text: action });
input.value = '';
await generateAIGMResponse({ type: 'player_action', prompt: action, storyLog, playerStatus });
};
document.getElementById('intervene-btn').onclick = () => switchView('master');
}
function setupMasterViewListeners(storyLog) {
document.getElementById('set-session-key-btn').onclick = () => { updateSessionKey(document.getElementById('session-key-input').value.trim()); };
document.getElementById('manual-save-btn').onclick = handleSave;
document.getElementById('manual-load-btn').onclick = handleLoad;
document.getElementById('copy-session-data-btn').onclick = () => {
const dataJsonEl = document.getElementById('session-data-json');
dataJsonEl.select();
document.execCommand('copy');
showModal("セッションデータJSONがクリップボードにコピーされました。");
};
document.getElementById('update-status-btn').onclick = () => {
const input = document.getElementById('status-json-input');
const statusMessageEl = document.getElementById('status-message');
try {
const newStatus = JSON.parse(input.value);
updateStatus(newStatus);
statusMessageEl.textContent = "✅ ステータスを更新しました。";
statusMessageEl.className = 'mt-2 text-sm text-green-400';
} catch (e) {
statusMessageEl.textContent = "❌ JSON形式が不正です。確認してください。";
statusMessageEl.className = 'mt-2 text-sm text-red-400';
}
};
document.getElementById('generate-narrative-btn').onclick = async () => {
const input = document.getElementById('gm-narrative-input');
const prompt = input.value.trim();
if (!prompt) return showModal("GMナラティブ生成の指示を入力してください。");
input.value = '';
await updateStoryLog({ role: 'System', text: `GMによる介入プロンプト: ${prompt}` });
await generateAIGMResponse({ type: 'gm_narrative', prompt: prompt, storyLog, playerStatus: latestGameData.playerStatus });
};
document.getElementById('prompt-roll-btn').onclick = async () => {
await updateStoryLog({ role: 'System', text: `GMがプレイヤーに行動判定ロールを促しています。` });
await generateAIGMResponse({ type: 'prompt_roll', prompt: "プレイヤーに行動判定ロールを促すナラティブ(例: 「さあ、君の交渉スキルを試す時だ!」)を生成してください。ロール結果が出るまで判定は保留します。", storyLog, playerStatus: latestGameData.playerStatus });
};
}
async function displaySummary(storyLog, playerStatus) {
const summaryEl = document.getElementById('session-summary');
summaryEl.innerHTML = `<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-6 w-6 border-b-2 border-fuchsia-400 mr-3"></div>要約データをAIが生成中...</div>`;
try {
const responseText = await generateAIGMResponse({ type: 'summary', prompt: "現在のセッションの状況、プレイヤーの目標、直前の展開、現在のステータスを簡潔に要約し、次のセッションに引き継げる形式で提供してください。", storyLog, playerStatus, retryCount: 0 });
summaryEl.textContent = responseText || "要約の生成に失敗しました。";
} catch (error) {
summaryEl.textContent = "要約の生成中にエラーが発生しました。";
}
}
async function generateAIGMResponse({ type, prompt, storyLog, playerStatus, retryCount = 0 }) {
showLoading(true);
const conversationHistory = storyLog.map(entry => ({
role: entry.role === 'GM' || entry.role === 'System' ? 'model' : 'user',
parts: [{ text: entry.text }]
}));
const statusSummary = Object.entries(playerStatus).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ');
const fullPrompt = `現在のステータス: [${statusSummary}]。あなたの指示: ${prompt}`;
conversationHistory.push({ role: 'user', parts: [{ text: fullPrompt }] });
const payload = {
contents: conversationHistory,
systemInstruction: { parts: [{ text: SYSTEM_INSTRUCTION_BASE }] },
};
try {
const response = await fetch(API_URL + API_KEY, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
if (response.status === 429 && retryCount < 3) {
const delay = Math.pow(2, retryCount) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
return generateAIGMResponse({ type, prompt, storyLog, playerStatus, retryCount: retryCount + 1 });
}
throw new Error(`API error with status: ${response.status}`);
}
const result = await response.json();
const text = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (type !== 'summary' && text) {
await updateStoryLog({ role: 'GM', text });
}
showLoading(false);
return text;
} catch (error) {
showLoading(false);
console.error("AI生成エラー:", error);
if (type !== 'summary') {
await updateStoryLog({ role: 'System', text: `AI-GM応答の生成に失敗しました。エラー: ${error.message}` });
} else {
return `エラーにより要約を生成できませんでした: ${error.message}`;
}
}
}
</script>
</body>
</html>
V0.93β デフォルトキーを、ユニークなるように変更(同じだと共有化されてしまうので)
冒険を一緒に遊べる機能ですが、逆に大問題なりますので、デフォルトキーを重ならないように初期設定を変更
あと、ダイスの出目でそのまま判定に進むバージョン、ズルしないでサクサク進みたい人向け
結果に納得できなければGMモードで修正可能
AIの危機感の無さは危なすぎる。
自分達でこのキーを申し合わせると一緒に冒険出来ますが、意味あるんかこの機能?
一応、化ける機能となるかもなので一応、保留中、何か出来るかもだが…うーん???
何人かで共有化して、皆が或いは誰かがGMのアシストやGMするとかも不可能ではないけど…
うーん??? 暫く試さないとわからんな
あと、「肥大化で一度にコピペ出来ない可能性」が出てきました。
その場合は分割して合わせる必要があるので、古いバージョンで我慢すると言うのも方法かもです。
そういう問題もバイブコーティングにはあるんだな…どっかで再設計しないとならんかも…。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI-GM TRPGシステム</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Inter', sans-serif; }
/* ログコンテナの高さ制限とスクロール設定 */
.log-container { max-height: 40vh; overflow-y: auto; scroll-behavior: smooth; }
/* ボタンのスタイル定義 */
.btn-inactive { background-color: #4b5563; color: #d1d5db; }
.btn-inactive:hover { background-color: #374151; }
.btn-player-active { background-color: #14b8a6; color: #1f2937; }
.btn-master-active { background-color: #e879f9; color: #1f2937; }
.btn-intervene { background-color: #f59e0b; color: #1f2937; }
.btn-intervene:hover { background-color: #d97706; }
.btn-roll-dice { background-color: #3b82f6; color: #ffffff; }
.btn-roll-dice:hover { background-color: #2563eb; }
.dice-result { font-size: 1.1rem; font-weight: bold; color: #fcd34d; text-shadow: 1px 1px 2px #000; }
.game-over-message { background-color: #dc2626; padding: 1rem; border-radius: 0.5rem; margin-top: 1rem; font-weight: bold; text-align: center; }
/* 警告スタイル */
.critical-warning {
background-color: #160a45; /* 暗い紫背景 */
border: 3px solid #9333ea; /* 紫の枠線 */
color: #d8b4fe; /* 薄い紫テキスト */
padding: 1rem;
border-radius: 0.75rem;
font-weight: 800; /* Extra bold */
font-size: 1rem;
}
</style>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen p-4 md:p-8">
<div id="app" class="max-w-6xl mx-auto bg-gray-800 shadow-2xl rounded-xl p-6 md:p-8">
<h1 class="text-3xl font-bold text-teal-400 mb-6 border-b border-teal-500/50 pb-2">
AI-GM TRPGシステム
</h1>
<div id="auth-info" class="mb-4 text-sm text-gray-400">
<p><strong>認証ユーザーID:</strong> <span id="user-id">...</span></p>
</div>
<div class="flex space-x-2 mb-6">
<button id="player-view-btn" class="px-4 py-2 rounded-lg font-semibold transition-all shadow-md focus:outline-none focus:ring-4 focus:ring-teal-500/50 btn-player-active">
プレイヤー画面
</button>
<button id="master-view-btn" class="px-4 py-2 rounded-lg font-semibold transition-all shadow-md focus:outline-none focus:ring-4 focus:ring-fuchsia-500/50 btn-inactive">
GM画面
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div id="main-view" class="lg:col-span-2 bg-gray-700 p-6 rounded-xl shadow-inner min-h-[50vh]">
</div>
<div class="lg:col-span-1 bg-gray-700 p-6 rounded-xl shadow-inner">
<h2 class="text-xl font-bold text-orange-400 mb-4 border-b border-orange-500/50 pb-2">
キャラクター・ステータス
</h2>
<div id="status-display"></div>
<div id="gm-status-editor" class="mt-6 hidden">
<h3 class="text-lg font-bold text-orange-400 mb-3">ステータス編集 (GM専用)</h3>
<textarea id="status-json-input" rows="8" class="w-full p-3 text-sm rounded-lg bg-gray-800 text-gray-100 border border-gray-600 focus:ring-orange-500 focus:border-orange-500" placeholder='{"HP": 100, "MP": 50, "経験値": 0, "所持品": "木剣、粗末な布服"}'></textarea>
<button id="update-status-btn" class="w-full mt-3 px-4 py-2 rounded-lg font-semibold bg-orange-600 hover:bg-orange-700 transition-colors shadow-md">
ステータス更新
</button>
<p id="status-message" class="mt-2 text-sm text-red-400"></p>
</div>
</div>
</div>
<div id="loading" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="text-white text-xl flex flex-col items-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-500 mb-3"></div>
ゲームマスターが思考中...
</div>
</div>
<!-- カスタムモーダル(alert()の代替) -->
<div id="custom-modal" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="bg-gray-700 p-6 rounded-xl shadow-2xl max-w-sm w-full text-center">
<p id="modal-text" class="text-lg text-gray-100 mb-4"></p>
<button id="modal-close" class="px-6 py-2 rounded-lg font-semibold bg-teal-600 hover:bg-teal-700 transition-colors">閉じる</button>
</div>
</div>
</div>
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, getDoc, setDoc, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// setLogLevel('Debug'); // デバッグが必要な場合はコメントを解除
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : null;
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
let db, auth;
let userId = 'loading'; // Firebaseの認証ID
let sessionKey = 'loading_key'; // 🚨 FIX: 初期値をプレースホルダーに変更
let unsubscribe = null; // Firestoreリスナー解除用
let currentView = 'player';
let latestGameData = null;
const SESSION_COLLECTION = 'gm_game_sessions';
// ゲームの初期データ
const initialData = {
storyLog: [{ role: 'GM', text: 'ようこそ。あなたの冒険が始まります。目の前には古びた洞窟の入り口があります。どうしますか?' }],
playerStatus: { HP: 100, MP: 50, '経験値': 0, '所持品': '木剣、粗末な布服' },
lastUpdate: new Date(),
};
if (firebaseConfig) {
try {
const app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
} catch (error) {
console.error("Firebaseの初期化に失敗しました:", error);
showModal("システムエラー: Firebaseの初期化に失敗しました。");
}
}
async function authenticate() {
try {
if (auth) {
if (initialAuthToken) {
await signInWithCustomToken(auth, initialAuthToken);
} else {
await signInAnonymously(auth);
}
}
} catch (error) {
console.error("認証エラー:", error);
showModal("認証に失敗しました。");
}
}
onAuthStateChanged(auth, (user) => {
if (user) {
userId = user.uid;
document.getElementById('user-id').textContent = userId;
// 🚨 FIX: セッションキーをユーザーID付きでユニーク化
const defaultUniqueKey = `Default_Session_${userId}`;
const storedKey = localStorage.getItem('last_session_key');
if (storedKey) {
sessionKey = storedKey; // 以前使用したキーがあればそれを使用
} else {
sessionKey = defaultUniqueKey; // 初回はユニークなキーを設定
localStorage.setItem('last_session_key', sessionKey); // 保存
}
setupFirestoreListener(); // 初期リスナー設定
} else {
authenticate();
}
});
// Firestoreのドキュメント参照を取得(**sessionKeyを使用**)
function getSessionDocRef() {
if (!db || !sessionKey || sessionKey === 'loading_key') {
console.error("DB接続またはセッションキーが未設定です。");
// データが使えない場合でもエラーを出さないための安全策(実際には認証後に呼ばれるはず)
return doc(db, 'artifacts', appId, 'public', 'data', SESSION_COLLECTION, 'TEMP_ERROR_DOC');
}
// データは常に sessionKey に紐づく(パブリック領域)
return doc(db, 'artifacts', appId, 'public', 'data', SESSION_COLLECTION, sessionKey);
}
// セッションキーの変更とリスナーの再設定
function updateSessionKey(newKey) {
if (newKey && newKey !== sessionKey) {
// 古いリスナーを解除
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
console.log(`古いセッションキー (${sessionKey}) のリスナーを解除しました。`);
}
// 新しいキーを設定
sessionKey = newKey;
localStorage.setItem('last_session_key', newKey);
console.log(`セッションキーを ${sessionKey} に変更しました。`);
// 新しいリスナーを設定
setupFirestoreListener();
} else if (!newKey) {
showModal("セッションキーを空にすることはできません。");
}
}
// セッションが存在しない場合に初期化する
async function initializeSession() {
try {
if (!db || sessionKey === 'loading_key') return;
const docSnap = await getDoc(getSessionDocRef());
if (!docSnap.exists()) {
await setDoc(getSessionDocRef(), initialData, { merge: true });
}
} catch (error) {
console.error("セッションの初期化に失敗しました:", error);
showModal(`セッション初期化エラー: データを保存できませんでした。キー: ${sessionKey}`);
}
}
// Firestoreのリアルタイムリスナーを設定する
function setupFirestoreListener() {
if (!db || sessionKey === 'loading_key' || userId === 'loading') return;
const docRef = getSessionDocRef();
// 初回またはキー変更時に初期化を試行
initializeSession();
// リスナーをセットアップし、解除関数を保存
unsubscribe = onSnapshot(docRef, (docSnap) => {
if (docSnap.exists()) {
const data = docSnap.data();
latestGameData = data;
renderGame(data);
} else {
initializeSession(); // ドキュメントが存在しない場合は再初期化を試みる
}
}, (error) => {
console.error("Firestoreリスナーエラー:", error);
showModal(`データ同期エラーが発生しました。セッションキー: ${sessionKey}`);
});
}
// ストーリーログに新しいエントリを追加する
async function updateStoryLog(newEntry) {
if (!db || sessionKey === 'loading_key') return null;
const docRef = getSessionDocRef();
try {
const docSnap = await getDoc(docRef);
const currentData = docSnap.data() || initialData;
const updatedLog = [...currentData.storyLog, newEntry];
await setDoc(docRef, {
storyLog: updatedLog,
lastUpdate: new Date()
}, { merge: true });
return updatedLog;
} catch (error) {
console.error("ログの更新に失敗しました:", error);
showModal("ログ更新エラー: データを保存できませんでした。");
return null;
}
}
// プレイヤーステータスを更新する
async function updateStatus(newStatus) {
if (!db || sessionKey === 'loading_key') return false;
const docRef = getSessionDocRef();
try {
await setDoc(docRef, {
playerStatus: newStatus,
lastUpdate: new Date()
}, { merge: true });
return true;
} catch (error) {
console.error("ステータスの更新に失敗しました:", error);
showModal("ステータス更新エラー: データを保存できませんでした。");
return false;
}
}
// メインビューをレンダリングする
function renderGame(data) {
const mainView = document.getElementById('main-view');
const playerBtn = document.getElementById('player-view-btn');
const masterBtn = document.getElementById('master-view-btn');
const logContainer = createLogContainer(data.storyLog);
const isGameOver = data.playerStatus.HP <= 0;
let gameOverMessage = isGameOver ? '<div class="game-over-message text-2xl text-white">ゲームオーバー</div>' : '';
// ダイスロールセクション
const diceRollSection = `
<div class="mb-4 p-4 bg-gray-600 rounded-xl shadow-lg ${isGameOver ? 'opacity-50 pointer-events-none' : ''}">
<h3 class="text-lg font-semibold text-white mb-3 border-b border-gray-500 pb-2">ダイスロール</h3>
<div class="flex flex-col space-y-3">
<!-- D100 ロール -->
<button data-dice="100" class="w-full px-6 py-2 rounded-lg font-bold btn-roll-dice transition-colors shadow-md flex items-center justify-center">
<span class="text-xl mr-2">🎲</span> D100 (パーセンテージ) ロール
</button>
<!-- カスタム ロール -->
<div class="flex space-x-2">
<input type="text" id="custom-dice-input" placeholder="例: 2D6+3" class="flex-grow p-2 rounded-lg bg-gray-800 text-gray-100 border border-gray-500 focus:ring-blue-500 focus:border-blue-500 text-sm">
<button data-dice="custom" class="flex-shrink-0 px-4 py-2 rounded-lg font-bold btn-roll-dice transition-colors shadow-md">
カスタムロール
</button>
</div>
<!-- D6 ロール -->
<button data-dice="6" class="w-full px-6 py-2 rounded-lg font-bold bg-gray-500 hover:bg-gray-600 transition-colors shadow-md flex items-center justify-center">
<span class="text-lg mr-2">🔹</span> D6 ロール
</button>
</div>
</div>
`;
renderStatusDisplay(data.playerStatus);
if (currentView === 'player') {
mainView.innerHTML = gameOverMessage + diceRollSection + renderPlayerView(logContainer, isGameOver);
setupPlayerViewListeners(data.storyLog, data.playerStatus);
document.getElementById('gm-status-editor').classList.add('hidden');
playerBtn.classList.remove('btn-inactive'); playerBtn.classList.add('btn-player-active');
masterBtn.classList.remove('btn-master-active'); masterBtn.classList.add('btn-inactive');
} else {
mainView.innerHTML = gameOverMessage + diceRollSection + renderMasterView(logContainer);
setupMasterViewListeners(data.storyLog);
document.getElementById('gm-status-editor').classList.remove('hidden');
masterBtn.classList.remove('btn-inactive'); masterBtn.classList.add('btn-master-active');
playerBtn.classList.remove('btn-player-active'); playerBtn.classList.add('btn-inactive');
// GM画面表示時に全データJSONを表示
const dataJsonElement = document.getElementById('session-data-json');
if (dataJsonElement) {
dataJsonElement.value = JSON.stringify(data, null, 2);
}
// GM画面表示時にセッション要約の生成を開始
displaySummary(data.storyLog, data.playerStatus);
}
setupDiceRollListener(isGameOver);
// GM画面でステータス編集用にJSONを表示
document.getElementById('status-json-input').value = JSON.stringify(data.playerStatus, null, 2);
// ログコンテナを最下部にスクロール
setTimeout(() => {
const scrollable = document.querySelector('.log-container');
if (scrollable) scrollable.scrollTop = scrollable.scrollHeight;
}, 100);
}
// ログコンテナのHTMLを生成する (変更なし)
function createLogContainer(storyLog) {
let logHTML = storyLog.map(entry => {
const isGM = entry.role === 'GM';
const isDice = entry.role === 'Dice';
const isSystem = entry.role === 'System';
let roleColor = isGM ? 'text-green-400' : isDice ? 'text-yellow-400' : isSystem ? 'text-red-400' : 'text-blue-400';
let bgColor = isGM ? 'bg-gray-800' : isDice ? 'bg-gray-800 border border-yellow-500/50' : isSystem ? 'bg-gray-800 border border-red-500/50' : 'bg-gray-700';
let align = isGM || isSystem ? 'text-left' : isDice ? 'text-center' : 'text-right';
return `
<div class="mb-3 ${align}">
<div class="${bgColor} inline-block p-3 rounded-xl shadow-md max-w-full lg:max-w-[70%]">
<span class="font-bold text-sm ${roleColor}">${entry.role}:</span>
<p class="mt-1 whitespace-pre-wrap text-gray-200 ${isDice ? 'dice-result' : ''}">${entry.text}</p>
</div>
</div>
`;
}).join('');
return `<div class="log-container space-y-3 p-2 bg-gray-900 rounded-lg border border-gray-600">${logHTML}</div>`;
}
// ステータス表示のHTMLを生成する (変更なし)
function renderStatusDisplay(status) {
const display = document.getElementById('status-display');
let html = '';
for (const [key, value] of Object.entries(status)) {
let displayValue = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : String(value);
if (key === 'HP' || key === 'MP') {
const maxVal = 100; // 仮の最大値
const color = key === 'HP' ? (value <= 20 ? 'bg-red-500' : 'bg-green-500') : 'bg-blue-500';
const percentage = Math.max(0, Math.min(100, (value / maxVal) * 100));
html += `
<div class="mb-2">
<p class="text-sm text-gray-300 mb-1">${key}: <span class="font-semibold text-white">${displayValue}</span></p>
<div class="w-full bg-gray-600 rounded-full h-2">
<div class="${color} h-2 rounded-full transition-all duration-500" style="width: ${percentage}%;"></div>
</div>
</div>
`;
} else {
// 所持品などの長い文字列もpre-wrapで改行を維持
html += `<p class="text-sm text-gray-300">${key}: <span class="font-semibold text-white whitespace-pre-wrap">${displayValue}</span></p>`;
}
}
display.innerHTML = html;
}
// プレイヤービューのHTMLを生成する (変更なし)
function renderPlayerView(logContainer, isGameOver) {
const disabledClass = isGameOver ? 'opacity-50 pointer-events-none' : '';
return `
<h2 class="text-2xl font-bold text-teal-400 mb-4">冒険の記録 (プレイヤー)</h2>
${logContainer}
<div class="mt-4 ${disabledClass}">
<h3 class="font-bold text-lg mb-2">あなたの行動</h3>
<textarea id="player-action-input" rows="4" class="w-full p-3 rounded-lg bg-gray-800 text-gray-100 border border-teal-500 focus:ring-teal-500 focus:border-teal-500 resize-none" placeholder="${isGameOver ? 'ゲームオーバーです。新しい冒険を始めるにはGM画面でステータスをリセットしてください。' : '洞窟に入ります、剣を抜きます、など'}" ${isGameOver ? 'disabled' : ''}></textarea>
<button id="send-action-btn" class="w-full mt-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-teal-400 hover:bg-teal-300 transition-colors shadow-lg">
行動を宣言し、GMの応答を待つ
</button>
<button id="intervene-btn" class="w-full mt-4 px-4 py-3 rounded-lg font-bold text-gray-900 btn-intervene hover:opacity-90 transition-opacity shadow-lg">
GMとして状況に介入する/ステータスを編集する
</button>
</div>
`;
}
// GMビューのHTMLを生成する (警告文を更新)
function renderMasterView(logContainer) {
return `
<h2 class="text-2xl font-bold text-fuchsia-400 mb-4">セッション管理 (ゲームマスター)</h2>
<!-- セッションキー設定 -->
<div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg border border-fuchsia-500/50">
<h3 class="font-bold text-lg text-fuchsia-400 mb-2">セッションキー設定 (永続ID)</h3>
<!-- 🚨 警告を更新 🚨 -->
<p class="critical-warning mb-4">
【💡重要】デフォルトでは**あなたのユーザーIDに紐づいたプライベートキー**が設定されています。
他の人と**共有して一緒に遊ぶ場合**は、必ず独自のキー(例: 「アルフの冒険-1028」)に変更し、相手に伝えてください。
</p>
<div class="flex space-x-2">
<input type="text" id="session-key-input" value="${sessionKey}" class="flex-grow p-2 rounded-lg bg-gray-800 text-gray-100 border border-fuchsia-500 focus:ring-fuchsia-500 focus:border-fuchsia-500 text-sm" placeholder="セッションID (例: DragonQuest-001)">
<button id="set-session-key-btn" class="flex-shrink-0 px-4 py-2 rounded-lg font-semibold bg-fuchsia-600 hover:bg-fuchsia-700 transition-colors shadow-md text-white">
キー設定
</button>
</div>
</div>
<!-- セーブ・同期管理 -->
<div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg">
<h3 class="font-bold text-lg text-fuchsia-400 mb-2 border-b border-fuchsia-500/50 pb-1">セーブ・同期管理</h3>
<div id="save-load-message" class="mb-3 text-sm min-h-[1.5rem] text-yellow-400"></div>
<div class="flex space-x-3">
<button id="manual-save-btn" class="flex-1 px-4 py-2 rounded-lg font-semibold bg-red-600 hover:bg-red-700 transition-colors shadow-md text-white">
手動で強制セーブ
</button>
<button id="manual-load-btn" class="flex-1 px-4 py-2 rounded-lg font-semibold bg-gray-600 hover:bg-gray-700 transition-colors shadow-md text-white">
強制リロード (最新同期)
</button>
</div>
</div>
<!-- NEW: セッションデータ (JSONバックアップ) -->
<div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg border border-fuchsia-500/50">
<h3 class="font-bold text-lg text-fuchsia-400 mb-2 border-b border-fuchsia-500/50 pb-1">
セッションデータ (JSONバックアップ)
</h3>
<p class="text-xs text-gray-400 mb-2">現在のセッションキー (${sessionKey}) に紐づく全てのデータです。</p>
<textarea id="session-data-json" rows="12" class="w-full p-3 text-sm rounded-lg bg-gray-800 text-gray-100 border border-gray-600 focus:ring-fuchsia-500 focus:border-fuchsia-500 resize-none font-mono"></textarea>
<button id="copy-session-data-btn" class="w-full mt-3 px-4 py-2 rounded-lg font-semibold bg-fuchsia-600 hover:bg-fuchsia-700 transition-colors shadow-md text-white">
セーブデータ全体をコピー
</button>
</div>
<!-- 引き継ぎ用セッション要約 -->
<div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg">
<h3 class="font-bold text-lg text-fuchsia-400 mb-2 border-b border-fuchsia-500/50 pb-1">引き継ぎ用セッション要約</h3>
<div id="session-summary" class="p-4 bg-gray-800 rounded-lg min-h-[5rem] text-sm text-gray-300">
<div class="flex items-center justify-center h-full">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-fuchsia-400 mr-3"></div>
要約データをAIが生成中...
</div>
</div>
</div>
${logContainer}
<div class="mt-4">
<h3 class="font-bold text-lg mb-2">GMナラティブ生成</h3>
<button id="prompt-roll-btn" class="w-full mt-2 mb-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-purple-400 hover:bg-purple-300 transition-colors shadow-lg">
判定ロールを促す指示を入力
</button>
<textarea id="gm-narrative-input" rows="6" class="w-full p-3 rounded-lg bg-gray-800 text-gray-100 border border-fuchsia-500 focus:ring-fuchsia-500 focus:border-fuchsia-500 resize-none" placeholder="AIへの指示内容を入力します。例:プレイヤーを絶体絶命の危機に陥らせろ。"></textarea>
<button id="generate-narrative-btn" class="w-full mt-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-fuchsia-400 hover:bg-fuchsia-300 transition-colors shadow-lg">
AI-GMに展開を生成させる
</button>
</div>
`;
}
// --- 以下、ビュー切り替え、ダイスロール、ステータス、GM/プレイヤービューのロジックは変更なし ---
// ビューを切り替える
function switchView(viewName) {
currentView = viewName;
if (latestGameData) {
renderGame(latestGameData);
}
}
document.getElementById('player-view-btn').addEventListener('click', () => switchView('player'));
document.getElementById('master-view-btn').addEventListener('click', () => switchView('master'));
// --- セーブ・ロード処理 (変更なし) ---
async function handleSave() {
const messageDiv = document.getElementById('save-load-message');
if (!db || !latestGameData || sessionKey === 'loading_key') {
messageDiv.textContent = 'エラー: データベース接続またはデータがありません。';
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-red-400';
return;
}
messageDiv.textContent = 'セーブ中...';
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-yellow-400';
const docRef = getSessionDocRef();
try {
// merge:false で現在のデータで完全に上書き(強制セーブ)
await setDoc(docRef, {
...latestGameData,
lastManualSave: new Date().toISOString()
}, { merge: false });
messageDiv.textContent = `✅ セーブ成功: ${new Date().toLocaleTimeString()}に保存されました。`;
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-green-400';
} catch (error) {
console.error("手動セーブに失敗しました:", error);
messageDiv.textContent = `❌ セーブ失敗: エラーが発生しました (${error.message})。セッションキー: ${sessionKey}`;
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-red-400';
}
// 5秒後にメッセージをクリア
setTimeout(() => { messageDiv.textContent = ''; }, 5000);
}
async function handleLoad() {
const messageDiv = document.getElementById('save-load-message');
if (!db || sessionKey === 'loading_key') {
messageDiv.textContent = 'エラー: データベース接続がありません。';
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-red-400';
return;
}
messageDiv.textContent = '最新データを強制リロード中...';
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-yellow-400';
try {
const docSnap = await getDoc(getSessionDocRef());
if (docSnap.exists()) {
const data = docSnap.data();
latestGameData = data;
renderGame(data); // 取得したデータで強制的にUIを更新
messageDiv.textContent = `✅ ロード成功: ${new Date().toLocaleTimeString()}のデータに更新しました。セッションキー: ${sessionKey}`;
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-green-400';
} else {
messageDiv.textContent = `❌ ロード失敗: セッションキー (${sessionKey}) のデータが見つかりません。`;
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-red-400';
}
} catch (error) {
console.error("強制ロードに失敗しました:", error);
messageDiv.textContent = `❌ ロード失敗: エラーが発生しました (${error.message})。`;
messageDiv.className = 'mb-3 text-sm min-h-[1.5rem] text-red-400';
}
// 5秒後にメッセージをクリア
setTimeout(() => { messageDiv.textContent = ''; }, 5000);
}
// --- ダイスロール、ステータス、プレイヤービューのロジック (変更なし/割愛) ---
// ダイス文字列をパースする (例: 2D6+3)
function parseDiceString(diceString) {
const match = diceString.match(/^(\d*)D(\d+)(?:\s*([+-])\s*(\d+))?$/i);
if (match) {
const numDice = parseInt(match[1] || '1', 10);
const sides = parseInt(match[2], 10);
const sign = match[3] || '+';
const modifier = parseInt(match[4] || '0', 10) * (sign === '-' ? -1 : 1);
// 実行可能な制限
if (numDice > 100 || sides > 1000) return null;
return { numDice, sides, modifier };
}
return null;
}
// ダイスを振る
function rollDice(numDice, sides, modifier = 0) {
let total = 0;
const rolls = [];
for (let i = 0; i < numDice; i++) {
const roll = Math.floor(Math.random() * sides) + 1;
rolls.push(roll);
total += roll;
}
const finalResult = total + modifier;
return { rolls, total, finalResult, modifier };
}
// ダイスロールボタンのリスナー設定
function setupDiceRollListener(isGameOver) {
const rollBtns = document.querySelectorAll('[data-dice]');
const customInput = document.getElementById('custom-dice-input');
rollBtns.forEach(btn => {
// 既存のイベントリスナーを削除(renderGameが毎回呼ばれるため)
btn.onclick = null;
if (!isGameOver) {
btn.onclick = async (e) => {
const diceType = e.currentTarget.dataset.dice;
let rollResult = null;
let rollString = '';
if (diceType === '6') {
rollString = '1D6';
rollResult = rollDice(1, 6);
} else if (diceType === '100') {
rollString = 'D100';
rollResult = rollDice(1, 100);
} else if (diceType === 'custom') {
const customStr = customInput.value.trim();
const parsed = parseDiceString(customStr);
if (parsed) {
rollString = `${parsed.numDice}D${parsed.sides}${parsed.modifier !== 0 ? (parsed.modifier > 0 ? '+' : ''): ''}${parsed.modifier !== 0 ? parsed.modifier : ''}`;
rollResult = rollDice(parsed.numDice, parsed.sides, parsed.modifier);
customInput.value = ''; // 成功したらクリア
} else {
showModal("カスタムダイスの形式が不正です。例: 2D6+3, D100, 3D8");
return;
}
}
if (rollResult) {
const { rolls, total, finalResult, modifier } = rollResult;
let message = `【${rollString} ロール (結果: ${finalResult})】\n`;
if (rolls.length > 1) {
message += `個別出目: [${rolls.join(', ')}] = ${total}`;
if (modifier !== 0) {
message += ` ${modifier > 0 ? '+' : ''}${modifier} (修正値)`;
}
message += `\n**最終結果: ${finalResult}**`;
} else if (modifier !== 0) {
message += `出目: ${total} ${modifier > 0 ? '+' : ''}${modifier} (修正値)\n**最終結果: ${finalResult}**`;
} else {
message += `出目: ${finalResult}`;
}
if (diceType === '100') {
message += finalResult <= 5 ? ' (クリティカル!) - 成功' : finalResult >= 96 ? ' (ファンブル...) - 失敗' : '';
}
await updateStoryLog({ role: 'Dice', text: message });
}
};
}
});
}
// ステータス編集のリスナー設定
document.getElementById('update-status-btn').addEventListener('click', () => {
const jsonInput = document.getElementById('status-json-input').value;
const statusMsg = document.getElementById('status-message');
statusMsg.textContent = '';
try {
const newStatus = JSON.parse(jsonInput);
if (typeof newStatus !== 'object' || newStatus === null || typeof newStatus.HP !== 'number') {
throw new Error("JSON形式が不正、またはHPフィールドが数値ではありません。");
}
updateStatus(newStatus);
statusMsg.textContent = 'ステータスを更新しました!';
statusMsg.classList.remove('text-red-400');
statusMsg.classList.add('text-green-400');
} catch (e) {
statusMsg.textContent = 'JSON形式が不正です: ' + e.message;
statusMsg.classList.remove('text-green-400');
statusMsg.classList.add('text-red-400');
}
});
// プレイヤービューのリスナー設定
function setupPlayerViewListeners(storyLog, currentStatus) {
const sendBtn = document.getElementById('send-action-btn');
const interveneBtn = document.getElementById('intervene-btn');
const isGameOver = currentStatus.HP <= 0;
if (interveneBtn) interveneBtn.onclick = () => switchView('master');
if (sendBtn) sendBtn.onclick = null;
if (sendBtn && !isGameOver) {
sendBtn.onclick = async () => {
const input = document.getElementById('player-action-input');
const action = input.value.trim();
if (!action) { showModal("プレイヤーの行動を入力してください。"); return; }
await updateStoryLog({ role: 'Player', text: action });
input.value = '';
document.getElementById('loading').classList.remove('hidden');
const gmInstruction = "直前のプレイヤーの行動をGMとして処理し、その結果(成功/失敗判定、または単なる描写)と次の展開を記述せよ。もし直前のログにダイスロール結果があれば、その結果に基づいて判定を行うこと。結果に応じて、現在のステータスを更新(HP/MPの増減)し、必ずJSON形式でナラティブと更新後のステータスを返却せよ。";
const fullLog = [...storyLog, { role: 'Player', text: action }];
const gmResponseObject = await generateNarrative(gmInstruction, fullLog, currentStatus, false);
document.getElementById('loading').classList.add('hidden');
if (gmResponseObject && gmResponseObject.narrative) {
const { narrative, updatedStatus } = gmResponseObject;
await updateStoryLog({ role: 'GM', text: narrative });
await updateStatus(updatedStatus);
if (updatedStatus.HP <= 0 && !narrative.includes("ゲームオーバー")) {
await updateStoryLog({ role: 'GM', text: 'あなたは致命的な一撃を受け、その場で倒れた。ゲームオーバーだ。' });
}
} else {
const fallbackNarrative = `【GM介入 (システム応答)】あなたの行動「${action}」により、物語が進行します。AI-GMへの接続に失敗したため、システムが暫定的に処理を行います。あなたは運良く洞窟の奥へと無傷で進むことができましたが、少し疲労しました。`;
const fallbackStatus = {
...currentStatus,
HP: currentStatus.HP > 5 ? currentStatus.HP - 5 : 0,
MP: currentStatus.MP
};
showModal(`GMの応答を生成できませんでした。システムのフォールバック応答で進行します。API呼び出しの制限を超過した可能性があります。`);
await updateStoryLog({ role: 'System', text: fallbackNarrative });
await updateStatus(fallbackStatus);
if (fallbackStatus.HP <= 0) {
await updateStoryLog({ role: 'GM', text: 'あなたは致命的な一撃を受け、その場で倒れた。ゲームオーバーだ。' });
}
}
};
}
}
// --- GM画面 ロジック (変更なし) ---
function setupMasterViewListeners(storyLog) {
const generateBtn = document.getElementById('generate-narrative-btn');
const input = document.getElementById('gm-narrative-input');
const promptRollBtn = document.getElementById('prompt-roll-btn');
const saveBtn = document.getElementById('manual-save-btn');
const loadBtn = document.getElementById('manual-load-btn');
const setKeyBtn = document.getElementById('set-session-key-btn');
const keyInput = document.getElementById('session-key-input');
// セッションキー設定ボタンのリスナー
if (setKeyBtn) {
setKeyBtn.onclick = () => {
const newKey = keyInput.value.trim();
updateSessionKey(newKey);
};
}
if (saveBtn) saveBtn.onclick = handleSave;
if (loadBtn) loadBtn.onclick = handleLoad;
if (promptRollBtn) {
promptRollBtn.onclick = () => {
input.value = "直前のプレイヤーの行動に対し、D100ロールによる判定を促し、その結果に基づいて物語の展開を記述せよ。";
showModal("判定ロールを促す定型文が入力されました。必要に応じて編集してください。");
};
}
// 既存のイベントリスナーを削除
if (generateBtn) generateBtn.onclick = null;
if (generateBtn) {
generateBtn.onclick = async () => {
const gmInstruction = input.value.trim();
if (!gmInstruction) { showModal("AIへの指示を入力してください。"); return; }
document.getElementById('loading').classList.remove('hidden');
const gmResponseObject = await generateNarrative(gmInstruction, storyLog, latestGameData?.playerStatus || initialData.playerStatus, true);
document.getElementById('loading').classList.add('hidden');
if (gmResponseObject && gmResponseObject.narrative) {
await updateStoryLog({ role: 'GM', text: gmResponseObject.narrative });
input.value = '';
} else {
showModal("GMナラティブの生成に失敗しました。AIからの応答が空か、または認証/API呼び出しエラーです。");
}
};
}
// NEW: セーブデータコピーボタンのリスナー
const copyDataBtn = document.getElementById('copy-session-data-btn');
if (copyDataBtn) {
// 既存のイベントリスナーを削除(renderGameが毎回呼ばれるため)
copyDataBtn.onclick = null;
copyDataBtn.onclick = () => {
const jsonTextarea = document.getElementById('session-data-json');
if (!jsonTextarea.value) {
showModal("コピーするデータがありません。データ同期を待つか、手動でリロードしてください。");
return;
}
// テキストエリアの内容を選択し、コピーを実行
try {
jsonTextarea.select();
document.execCommand('copy');
showModal("✅ セッションデータ全体をクリップボードにコピーしました!");
} catch (err) {
console.error('コピー失敗:', err);
showModal("❌ コピーに失敗しました。手動でテキストエリアの内容をコピーしてください。");
}
};
}
}
// --- 要約表示ロジック (変更なし) ---
async function displaySummary(storyLog, currentStatus) {
const summaryDiv = document.getElementById('session-summary');
// 要約生成前のローディング表示
summaryDiv.innerHTML = `
<div class="flex items-center h-full text-fuchsia-400">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-fuchsia-400 mr-3"></div>
要約データをAIが生成中...
</div>`;
const summary = await generateSummary(storyLog, currentStatus);
if (summary && summary.currentLocation) {
summaryDiv.innerHTML = `
<div class="space-y-3 text-gray-300">
<p class="font-bold text-sm text-fuchsia-300">セッションID (キー):</p>
<p class="pl-4 text-white text-xs break-all font-mono">${sessionKey}</p>
<p class="font-bold">現在の場所:</p>
<p class="pl-4 text-white">${summary.currentLocation}</p>
<p class="font-bold">直近の出来事:</p>
<p class="pl-4 text-white">${summary.latestConflict}</p>
<p class="font-bold">プレイヤー状態:</p>
<p class="pl-4 text-white">${summary.playerCondition}</p>
</div>
`;
} else {
summaryDiv.innerHTML = '<p class="text-red-400">要約の生成に失敗しました。ログが不足しているか、AI応答にエラーがありました。</p>';
}
}
// --- Gemini API 連携 ロジック (変更なし) ---
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`;
// AIの応答スキーマ定義 (ナラティブ生成用)
const narrativeResponseSchema = {
type: "OBJECT",
properties: {
narrative: { type: "STRING", description: "GMとして生成する物語の展開。" },
updatedStatus: {
type: "OBJECT",
description: "更新されたプレイヤーのステータス。HPが0になった場合は必ず「ゲームオーバーだ。」という文章をナラティブに含めること。",
properties: {
HP: { type: "NUMBER" },
MP: { type: "NUMBER" },
'経験値': { type: "NUMBER" },
'所持品': { "type": "STRING" }
},
required: ["HP", "MP", "経験値", "所持品"]
}
},
required: ["narrative"],
};
async function generateNarrative(gmInstruction, storyLog, currentStatus, isMasterInput = false) {
const context = storyLog.slice(-10).map(e => `${e.role}: ${e.text}`).join('\n'); // ログを最新10件に制限
const statusInstruction = isMasterInput
? "GMからの指示によるナラティブのみを生成し、updatedStatusオブジェクトは含めないでください。この場合、responseSchemaのupdatedStatusは無視してください。"
: "プレイヤーの行動の結果として、必ずupdatedStatusオブジェクトを含め、HP/MPの増減を反映させてください。HPが0以下になった場合、必ずナラティブに「ゲームオーバーだ。」という文章を加えてください。";
const systemPrompt = `
あなたはファンタジーTRPGのゲームマスター(GM)です。以下の制約に従って応答を生成してください。
**GMの役割と制約**
1. 応答は必ずJSON形式で、ナラティブを含めること。
2. ログ内の 'Dice' エントリの結果(例: 【1D6 ロール (結果: 5)】)を最優先で参照し、その目に基づいて判定を行うこと。
3. ${statusInstruction}
**現在のプレイヤー情報**
${JSON.stringify(currentStatus, null, 2)}
`;
const userQuery = `
--- 直近のストーリーログ ---
${context}
--- GMからのAIへの具体的な指示 ---
${gmInstruction}
`;
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
responseSchema: narrativeResponseSchema
}
};
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
if (response.status === 401) throw new Error(`認証エラー (401): APIキーが正しくありません。`);
if (response.status === 429 && i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw new Error(`APIエラー: ${response.status} ${response.statusText}`);
}
const result = await response.json();
const jsonString = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (jsonString) {
try {
const gmResponse = JSON.parse(jsonString);
if (isMasterInput) {
if (gmResponse.narrative) {
return { narrative: gmResponse.narrative };
}
} else {
if (gmResponse.narrative && gmResponse.updatedStatus) {
return gmResponse;
}
}
throw new Error("AI応答のJSON構造が不完全です。");
} catch (e) {
console.error("AI応答のJSONパースまたは構造検証エラー:", e, "Raw Text:", jsonString);
return null;
}
} else {
console.error("AIからの応答が空でした:", result);
return null;
}
} catch (error) {
console.error(`API呼び出し試行 ${i + 1}/${maxRetries} でエラーが発生しました:`, error.message);
if (i < maxRetries - 1) {
if (error.message.includes("401")) {
break;
}
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
} else {
console.error("API呼び出しが最大リトライ回数に達しました。");
return null;
}
}
}
return null;
}
// --- セッション要約生成ロジック (変更なし) ---
async function generateSummary(storyLog, currentStatus) {
const summarySchema = {
type: "OBJECT",
properties: {
currentLocation: { type: "STRING", description: "プレイヤーが現在いる場所や場面。" },
latestConflict: { type: "STRING", description: "直前の主要な出来事や戦闘、または解決すべき問題。" },
playerCondition: { type: "STRING", description: "プレイヤーのHP/MP、重要な所持品、精神状態など。" }
},
required: ["currentLocation", "latestConflict", "playerCondition"]
};
const systemPrompt = `
あなたはTRPGセッションのログを分析するアシスタントです。GMの引き継ぎのために、直近の展開とプレイヤーの状況を構造化されたJSON形式で要約してください。
制約: 記述は簡潔で、GMがすぐに状況を把握できるようにしてください。
`;
const context = storyLog.slice(-20).map(e => `${e.role}: ${e.text}`).join('\n');
const userQuery = `
--- 直近のストーリーログ ---
${context}
--- 現在のステータス ---
${JSON.stringify(currentStatus, null, 2)}
上記の情報を分析し、JSONスキーマに従って要約を生成してください。
`;
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
responseSchema: summarySchema
}
};
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
if (response.status === 429 && i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw new Error(`APIエラー: ${response.status} ${response.statusText}`);
}
const result = await response.json();
const jsonString = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {
console.error("要約JSONパースエラー:", e, "Raw Text:", jsonString);
return null;
}
}
return null;
} catch (error) {
console.error(`要約API呼び出し試行 ${i + 1}/${maxRetries} でエラーが発生しました:`, error.message);
}
}
return null;
}
// --- モーダル表示関数 ---
function showModal(message) {
const modal = document.getElementById('custom-modal');
document.getElementById('modal-text').textContent = message;
modal.classList.remove('hidden');
}
document.getElementById('modal-close').addEventListener('click', () => {
document.getElementById('custom-modal').classList.add('hidden');
});
// 初回ロード時の処理
if (firebaseConfig) {
authenticate();
} else {
console.error("Firebase設定が見つかりません。データは永続化されず、AI機能も動作しません。");
document.getElementById('main-view').innerHTML = '<div class="text-red-400 p-4">Firebase設定が見つかりません。データは永続化されず、AI機能は動作しません。</div>';
latestGameData = initialData;
renderGame(initialData);
}
</script>
</body>
</html>
V0.92β セーブ機能を3種類追加
基本的には気にしなくて良いけど本当に大切なキャラクターに育ったらバックアップしとこう
適当で良いなら口頭でもなんとかなるよ(多分)
尚、会話ログはサーバーに保存されます。
冒険中に個人情報とかは話すのはかなりお勧めしません。
エロログが漏洩とかはないといいなー(滝汗)
V0.92β はデフォルトキーは必ず変更してください!
共有の恐れがあります。
(調整済、危険なので速攻V0.93βの改良型をリリースして対応)
V0.91β(引き継ぎ要約表示追加)
※詳細な移行なら、ログをコピーか「自分の記憶による申請すら受け付けます」
早い話がどんなスタートアップも可能です(笑)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI-GM TRPGシステム</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Inter', sans-serif; }
/* ログコンテナの高さ制限とスクロール設定 */
.log-container { max-height: 40vh; overflow-y: auto; scroll-behavior: smooth; }
/* ボタンのスタイル定義 */
.btn-inactive { background-color: #4b5563; color: #d1d5db; }
.btn-inactive:hover { background-color: #374151; }
.btn-player-active { background-color: #14b8a6; color: #1f2937; }
.btn-master-active { background-color: #e879f9; color: #1f2937; }
.btn-intervene { background-color: #f59e0b; color: #1f2937; }
.btn-intervene:hover { background-color: #d97706; }
.btn-roll-dice { background-color: #3b82f6; color: #ffffff; }
.btn-roll-dice:hover { background-color: #2563eb; }
.dice-result { font-size: 1.1rem; font-weight: bold; color: #fcd34d; text-shadow: 1px 1px 2px #000; }
.game-over-message { background-color: #dc2626; padding: 1rem; border-radius: 0.5rem; margin-top: 1rem; font-weight: bold; text-align: center; }
</style>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen p-4 md:p-8">
<div id="app" class="max-w-6xl mx-auto bg-gray-800 shadow-2xl rounded-xl p-6 md:p-8">
<h1 class="text-3xl font-bold text-teal-400 mb-6 border-b border-teal-500/50 pb-2">
AI-GM TRPGシステム
</h1>
<div id="auth-info" class="mb-4 text-sm text-gray-400">
<p><strong>セッションID:</strong> <span id="user-id">...</span></p>
</div>
<div class="flex space-x-2 mb-6">
<button id="player-view-btn" class="px-4 py-2 rounded-lg font-semibold transition-all shadow-md focus:outline-none focus:ring-4 focus:ring-teal-500/50 btn-player-active">
プレイヤー画面
</button>
<button id="master-view-btn" class="px-4 py-2 rounded-lg font-semibold transition-all shadow-md focus:outline-none focus:ring-4 focus:ring-fuchsia-500/50 btn-inactive">
GM画面
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div id="main-view" class="lg:col-span-2 bg-gray-700 p-6 rounded-xl shadow-inner min-h-[50vh]">
</div>
<div class="lg:col-span-1 bg-gray-700 p-6 rounded-xl shadow-inner">
<h2 class="text-xl font-bold text-orange-400 mb-4 border-b border-orange-500/50 pb-2">
キャラクター・ステータス
</h2>
<div id="status-display"></div>
<div id="gm-status-editor" class="mt-6 hidden">
<h3 class="text-lg font-bold text-orange-400 mb-3">ステータス編集 (GM専用)</h3>
<textarea id="status-json-input" rows="8" class="w-full p-3 text-sm rounded-lg bg-gray-800 text-gray-100 border border-gray-600 focus:ring-orange-500 focus:border-orange-500" placeholder='{"HP": 100, "MP": 50, "経験値": 0, "所持品": "木剣、粗末な布服"}'></textarea>
<button id="update-status-btn" class="w-full mt-3 px-4 py-2 rounded-lg font-semibold bg-orange-600 hover:bg-orange-700 transition-colors shadow-md">
ステータス更新
</button>
<p id="status-message" class="mt-2 text-sm text-red-400"></p>
</div>
</div>
</div>
<div id="loading" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="text-white text-xl flex flex-col items-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-500 mb-3"></div>
ゲームマスターが思考中...
</div>
</div>
<!-- カスタムモーダル(alert()の代替) -->
<div id="custom-modal" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="bg-gray-700 p-6 rounded-xl shadow-2xl max-w-sm w-full text-center">
<p id="modal-text" class="text-lg text-gray-100 mb-4"></p>
<button id="modal-close" class="px-6 py-2 rounded-lg font-semibold bg-teal-600 hover:bg-teal-700 transition-colors">閉じる</button>
</div>
</div>
</div>
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, getDoc, setDoc, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// setLogLevel('Debug'); // デバッグが必要な場合はコメントを解除
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : null;
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
let db, auth;
let userId = 'loading';
let currentView = 'player';
let latestGameData = null;
const SESSION_COLLECTION = 'gm_game_sessions';
// ゲームの初期データ
const initialData = {
storyLog: [{ role: 'GM', text: 'ようこそ。あなたの冒険が始まります。目の前には古びた洞窟の入り口があります。どうしますか?' }],
playerStatus: { HP: 100, MP: 50, '経験値': 0, '所持品': '木剣、粗末な布服' },
lastUpdate: new Date(),
};
if (firebaseConfig) {
try {
const app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
} catch (error) {
console.error("Firebaseの初期化に失敗しました:", error);
showModal("システムエラー: Firebaseの初期化に失敗しました。");
}
}
async function authenticate() {
try {
if (auth) {
if (initialAuthToken) {
await signInWithCustomToken(auth, initialAuthToken);
} else {
await signInAnonymously(auth);
}
}
} catch (error) {
console.error("認証エラー:", error);
showModal("認証に失敗しました。");
}
}
onAuthStateChanged(auth, (user) => {
if (user) {
userId = user.uid;
document.getElementById('user-id').textContent = userId;
setupFirestoreListener();
} else {
authenticate();
}
});
// Firestoreのドキュメント参照を取得(公開データパス)
function getSessionDocRef() {
return doc(db, 'artifacts', appId, 'public', 'data', SESSION_COLLECTION, userId);
}
// セッションが存在しない場合に初期化する
async function initializeSession() {
try {
if (!db) return;
const docSnap = await getDoc(getSessionDocRef());
if (!docSnap.exists()) {
await setDoc(getSessionDocRef(), initialData, { merge: true });
}
} catch (error) {
console.error("セッションの初期化に失敗しました:", error);
showModal("セッション初期化エラー: データを保存できませんでした。");
}
}
// Firestoreのリアルタイムリスナーを設定する
function setupFirestoreListener() {
if (!db) return;
const docRef = getSessionDocRef();
initializeSession();
onSnapshot(docRef, (docSnap) => {
if (docSnap.exists()) {
const data = docSnap.data();
latestGameData = data;
renderGame(data);
} else {
initializeSession(); // ドキュメントが削除された場合は再初期化を試みる
}
}, (error) => {
console.error("Firestoreリスナーエラー:", error);
showModal("データ同期エラーが発生しました。");
});
}
// ストーリーログに新しいエントリを追加する
async function updateStoryLog(newEntry) {
if (!db) return null;
const docRef = getSessionDocRef();
try {
const docSnap = await getDoc(docRef);
const currentData = docSnap.data() || initialData;
const updatedLog = [...currentData.storyLog, newEntry];
await setDoc(docRef, {
storyLog: updatedLog,
lastUpdate: new Date()
}, { merge: true });
return updatedLog;
} catch (error) {
console.error("ログの更新に失敗しました:", error);
showModal("ログ更新エラー: データを保存できませんでした。");
return null;
}
}
// プレイヤーステータスを更新する
async function updateStatus(newStatus) {
if (!db) return false;
const docRef = getSessionDocRef();
try {
await setDoc(docRef, {
playerStatus: newStatus,
lastUpdate: new Date()
}, { merge: true });
return true;
} catch (error) {
console.error("ステータスの更新に失敗しました:", error);
showModal("ステータス更新エラー: データを保存できませんでした。");
return false;
}
}
// メインビューをレンダリングする
function renderGame(data) {
const mainView = document.getElementById('main-view');
const playerBtn = document.getElementById('player-view-btn');
const masterBtn = document.getElementById('master-view-btn');
const logContainer = createLogContainer(data.storyLog);
const isGameOver = data.playerStatus.HP <= 0;
let gameOverMessage = isGameOver ? '<div class="game-over-message text-2xl text-white">ゲームオーバー</div>' : '';
// ダイスロールセクション
const diceRollSection = `
<div class="mb-4 p-4 bg-gray-600 rounded-xl shadow-lg ${isGameOver ? 'opacity-50 pointer-events-none' : ''}">
<h3 class="text-lg font-semibold text-white mb-3 border-b border-gray-500 pb-2">ダイスロール</h3>
<div class="flex flex-col space-y-3">
<!-- D100 ロール -->
<button data-dice="100" class="w-full px-6 py-2 rounded-lg font-bold btn-roll-dice transition-colors shadow-md flex items-center justify-center">
<span class="text-xl mr-2">🎲</span> D100 (パーセンテージ) ロール
</button>
<!-- カスタム ロール -->
<div class="flex space-x-2">
<input type="text" id="custom-dice-input" placeholder="例: 2D6+3" class="flex-grow p-2 rounded-lg bg-gray-800 text-gray-100 border border-gray-500 focus:ring-blue-500 focus:border-blue-500 text-sm">
<button data-dice="custom" class="flex-shrink-0 px-4 py-2 rounded-lg font-bold btn-roll-dice transition-colors shadow-md">
カスタムロール
</button>
</div>
<!-- D6 ロール -->
<button data-dice="6" class="w-full px-6 py-2 rounded-lg font-bold bg-gray-500 hover:bg-gray-600 transition-colors shadow-md flex items-center justify-center">
<span class="text-lg mr-2">🔹</span> D6 ロール
</button>
</div>
</div>
`;
renderStatusDisplay(data.playerStatus);
if (currentView === 'player') {
mainView.innerHTML = gameOverMessage + diceRollSection + renderPlayerView(logContainer, isGameOver);
setupPlayerViewListeners(data.storyLog, data.playerStatus);
document.getElementById('gm-status-editor').classList.add('hidden');
playerBtn.classList.remove('btn-inactive'); playerBtn.classList.add('btn-player-active');
masterBtn.classList.remove('btn-master-active'); masterBtn.classList.add('btn-inactive');
} else {
mainView.innerHTML = gameOverMessage + diceRollSection + renderMasterView(logContainer);
setupMasterViewListeners(data.storyLog);
document.getElementById('gm-status-editor').classList.remove('hidden');
masterBtn.classList.remove('btn-inactive'); masterBtn.classList.add('btn-master-active');
playerBtn.classList.remove('btn-player-active'); playerBtn.classList.add('btn-inactive');
// NEW: GM画面表示時にセッション要約の生成を開始
displaySummary(data.storyLog, data.playerStatus);
}
setupDiceRollListener(isGameOver);
// GM画面でステータス編集用にJSONを表示
document.getElementById('status-json-input').value = JSON.stringify(data.playerStatus, null, 2);
// ログコンテナを最下部にスクロール
setTimeout(() => {
const scrollable = document.querySelector('.log-container');
if (scrollable) scrollable.scrollTop = scrollable.scrollHeight;
}, 100);
}
// ログコンテナのHTMLを生成する
function createLogContainer(storyLog) {
let logHTML = storyLog.map(entry => {
const isGM = entry.role === 'GM';
const isDice = entry.role === 'Dice';
const isSystem = entry.role === 'System';
let roleColor = isGM ? 'text-green-400' : isDice ? 'text-yellow-400' : isSystem ? 'text-red-400' : 'text-blue-400';
let bgColor = isGM ? 'bg-gray-800' : isDice ? 'bg-gray-800 border border-yellow-500/50' : isSystem ? 'bg-gray-800 border border-red-500/50' : 'bg-gray-700';
let align = isGM || isSystem ? 'text-left' : isDice ? 'text-center' : 'text-right';
return `
<div class="mb-3 ${align}">
<div class="${bgColor} inline-block p-3 rounded-xl shadow-md max-w-full lg:max-w-[70%]">
<span class="font-bold text-sm ${roleColor}">${entry.role}:</span>
<p class="mt-1 whitespace-pre-wrap text-gray-200 ${isDice ? 'dice-result' : ''}">${entry.text}</p>
</div>
</div>
`;
}).join('');
return `<div class="log-container space-y-3 p-2 bg-gray-900 rounded-lg border border-gray-600">${logHTML}</div>`;
}
// ステータス表示のHTMLを生成する
function renderStatusDisplay(status) {
const display = document.getElementById('status-display');
let html = '';
for (const [key, value] of Object.entries(status)) {
let displayValue = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : String(value);
if (key === 'HP' || key === 'MP') {
const maxVal = 100; // 仮の最大値
const color = key === 'HP' ? (value <= 20 ? 'bg-red-500' : 'bg-green-500') : 'bg-blue-500';
const percentage = Math.max(0, Math.min(100, (value / maxVal) * 100));
html += `
<div class="mb-2">
<p class="text-sm text-gray-300 mb-1">${key}: <span class="font-semibold text-white">${displayValue}</span></p>
<div class="w-full bg-gray-600 rounded-full h-2">
<div class="${color} h-2 rounded-full transition-all duration-500" style="width: ${percentage}%;"></div>
</div>
</div>
`;
} else {
// 所持品などの長い文字列もpre-wrapで改行を維持
html += `<p class="text-sm text-gray-300">${key}: <span class="font-semibold text-white whitespace-pre-wrap">${displayValue}</span></p>`;
}
}
display.innerHTML = html;
}
// プレイヤービューのHTMLを生成する
function renderPlayerView(logContainer, isGameOver) {
const disabledClass = isGameOver ? 'opacity-50 pointer-events-none' : '';
return `
<h2 class="text-2xl font-bold text-teal-400 mb-4">冒険の記録 (プレイヤー)</h2>
${logContainer}
<div class="mt-4 ${disabledClass}">
<h3 class="font-bold text-lg mb-2">あなたの行動</h3>
<textarea id="player-action-input" rows="4" class="w-full p-3 rounded-lg bg-gray-800 text-gray-100 border border-teal-500 focus:ring-teal-500 focus:border-teal-500 resize-none" placeholder="${isGameOver ? 'ゲームオーバーです。新しい冒険を始めるにはGM画面でステータスをリセットしてください。' : '洞窟に入ります、剣を抜きます、など'}" ${isGameOver ? 'disabled' : ''}></textarea>
<button id="send-action-btn" class="w-full mt-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-teal-400 hover:bg-teal-300 transition-colors shadow-lg">
行動を宣言し、GMの応答を待つ
</button>
<button id="intervene-btn" class="w-full mt-4 px-4 py-3 rounded-lg font-bold text-gray-900 btn-intervene hover:opacity-90 transition-opacity shadow-lg">
GMとして状況に介入する/ステータスを編集する
</button>
</div>
`;
}
// GMビューのHTMLを生成する (要約セクションを追加)
function renderMasterView(logContainer) {
return `
<h2 class="text-2xl font-bold text-fuchsia-400 mb-4">セッション管理 (ゲームマスター)</h2>
<div class="mb-6 p-4 bg-gray-700 rounded-xl shadow-lg">
<h3 class="font-bold text-lg text-fuchsia-400 mb-2 border-b border-fuchsia-500/50 pb-1">引き継ぎ用セッション要約</h3>
<div id="session-summary" class="p-4 bg-gray-800 rounded-lg min-h-[5rem] text-sm text-gray-300">
<div class="flex items-center justify-center h-full">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-fuchsia-400 mr-3"></div>
要約データをAIが生成中...
</div>
</div>
</div>
${logContainer}
<div class="mt-4">
<h3 class="font-bold text-lg mb-2">GMナラティブ生成</h3>
<button id="prompt-roll-btn" class="w-full mt-2 mb-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-purple-400 hover:bg-purple-300 transition-colors shadow-lg">
判定ロールを促す指示を入力
</button>
<textarea id="gm-narrative-input" rows="6" class="w-full p-3 rounded-lg bg-gray-800 text-gray-100 border border-fuchsia-500 focus:ring-fuchsia-500 focus:border-fuchsia-500 resize-none" placeholder="AIへの指示内容を入力します。例:プレイヤーを絶体絶命の危機に陥らせろ。"></textarea>
<button id="generate-narrative-btn" class="w-full mt-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-fuchsia-400 hover:bg-fuchsia-300 transition-colors shadow-lg">
AI-GMに展開を生成させる
</button>
</div>
`;
}
// ビューを切り替える
function switchView(viewName) {
currentView = viewName;
if (latestGameData) {
renderGame(latestGameData);
}
}
document.getElementById('player-view-btn').addEventListener('click', () => switchView('player'));
document.getElementById('master-view-btn').addEventListener('click', () => switchView('master'));
// --- ダイスロール ロジック (変更なし) ---
// ダイス文字列をパースする (例: 2D6+3)
function parseDiceString(diceString) {
const match = diceString.match(/^(\d*)D(\d+)(?:\s*([+-])\s*(\d+))?$/i);
if (match) {
const numDice = parseInt(match[1] || '1', 10);
const sides = parseInt(match[2], 10);
const sign = match[3] || '+';
const modifier = parseInt(match[4] || '0', 10) * (sign === '-' ? -1 : 1);
// 実行可能な制限
if (numDice > 100 || sides > 1000) return null;
return { numDice, sides, modifier };
}
return null;
}
// ダイスを振る
function rollDice(numDice, sides, modifier = 0) {
let total = 0;
const rolls = [];
for (let i = 0; i < numDice; i++) {
const roll = Math.floor(Math.random() * sides) + 1;
rolls.push(roll);
total += roll;
}
const finalResult = total + modifier;
return { rolls, total, finalResult, modifier };
}
// ダイスロールボタンのリスナー設定
function setupDiceRollListener(isGameOver) {
const rollBtns = document.querySelectorAll('[data-dice]');
const customInput = document.getElementById('custom-dice-input');
rollBtns.forEach(btn => {
// 既存のイベントリスナーを削除(renderGameが毎回呼ばれるため)
btn.onclick = null;
if (!isGameOver) {
btn.onclick = async (e) => {
const diceType = e.currentTarget.dataset.dice;
let rollResult = null;
let rollString = '';
if (diceType === '6') {
rollString = '1D6';
rollResult = rollDice(1, 6);
} else if (diceType === '100') {
rollString = 'D100';
rollResult = rollDice(1, 100);
} else if (diceType === 'custom') {
const customStr = customInput.value.trim();
const parsed = parseDiceString(customStr);
if (parsed) {
rollString = `${parsed.numDice}D${parsed.sides}${parsed.modifier !== 0 ? (parsed.modifier > 0 ? '+' : '') + parsed.modifier : ''}`;
rollResult = rollDice(parsed.numDice, parsed.sides, parsed.modifier);
customInput.value = ''; // 成功したらクリア
} else {
showModal("カスタムダイスの形式が不正です。例: 2D6+3, D100, 3D8");
return;
}
}
if (rollResult) {
const { rolls, total, finalResult, modifier } = rollResult;
let message = `【${rollString} ロール (結果: ${finalResult})】\n`;
if (rolls.length > 1) {
message += `個別出目: [${rolls.join(', ')}] = ${total}`;
if (modifier !== 0) {
message += ` ${modifier > 0 ? '+' : ''}${modifier} (修正値)`;
}
message += `\n**最終結果: ${finalResult}**`;
} else if (modifier !== 0) {
message += `出目: ${total} ${modifier > 0 ? '+' : ''}${modifier} (修正値)\n**最終結果: ${finalResult}**`;
} else {
message += `出目: ${finalResult}`;
}
if (diceType === '100') {
message += finalResult <= 5 ? ' (クリティカル!) - 成功' : finalResult >= 96 ? ' (ファンブル...) - 失敗' : '';
}
await updateStoryLog({ role: 'Dice', text: message });
}
};
}
});
}
// --- ステータス編集 ロジック (変更なし) ---
document.getElementById('update-status-btn').addEventListener('click', () => {
const jsonInput = document.getElementById('status-json-input').value;
const statusMsg = document.getElementById('status-message');
statusMsg.textContent = '';
try {
const newStatus = JSON.parse(jsonInput);
// 必須フィールドの簡易チェック
if (typeof newStatus !== 'object' || newStatus === null || typeof newStatus.HP !== 'number') {
throw new Error("JSON形式が不正、またはHPフィールドが数値ではありません。");
}
updateStatus(newStatus);
statusMsg.textContent = 'ステータスを更新しました!';
statusMsg.classList.remove('text-red-400');
statusMsg.classList.add('text-green-400');
} catch (e) {
statusMsg.textContent = 'JSON形式が不正です: ' + e.message;
statusMsg.classList.remove('text-green-400');
statusMsg.classList.add('text-red-400');
}
});
// --- プレイヤー画面 ロジック (変更なし) ---
function setupPlayerViewListeners(storyLog, currentStatus) {
const sendBtn = document.getElementById('send-action-btn');
const interveneBtn = document.getElementById('intervene-btn');
const isGameOver = currentStatus.HP <= 0;
if (interveneBtn) interveneBtn.onclick = () => switchView('master');
// 既存のイベントリスナーを削除
if (sendBtn) sendBtn.onclick = null;
if (sendBtn && !isGameOver) {
sendBtn.onclick = async () => {
const input = document.getElementById('player-action-input');
const action = input.value.trim();
if (!action) { showModal("プレイヤーの行動を入力してください。"); return; }
await updateStoryLog({ role: 'Player', text: action });
input.value = '';
document.getElementById('loading').classList.remove('hidden');
const gmInstruction = "直前のプレイヤーの行動をGMとして処理し、その結果(成功/失敗判定、または単なる描写)と次の展開を記述せよ。もし直前のログにダイスロール結果があれば、その結果に基づいて判定を行うこと。結果に応じて、現在のステータスを更新(HP/MPの増減)し、必ずJSON形式でナラティブと更新後のステータスを返却せよ。";
const fullLog = [...storyLog, { role: 'Player', text: action }];
const gmResponseObject = await generateNarrative(gmInstruction, fullLog, currentStatus, false);
document.getElementById('loading').classList.add('hidden');
if (gmResponseObject && gmResponseObject.narrative) {
const { narrative, updatedStatus } = gmResponseObject;
await updateStoryLog({ role: 'GM', text: narrative });
await updateStatus(updatedStatus);
if (updatedStatus.HP <= 0 && !narrative.includes("ゲームオーバー")) {
// HPが0になったがGMがゲームオーバー宣言を忘れた場合の補完
await updateStoryLog({ role: 'GM', text: 'あなたは致命的な一撃を受け、その場で倒れた。ゲームオーバーだ。' });
}
} else {
// **フォールバック処理: APIエラー発生時の代替応答**
const fallbackNarrative = `【GM介入 (システム応答)】あなたの行動「${action}」により、物語が進行します。AI-GMへの接続に失敗したため、システムが暫定的に処理を行います。あなたは運良く洞窟の奥へと無傷で進むことができましたが、少し疲労しました。`;
const fallbackStatus = {
...currentStatus,
HP: currentStatus.HP > 5 ? currentStatus.HP - 5 : 0, // HPを少し減らす
MP: currentStatus.MP
};
showModal(`GMの応答を生成できませんでした。システムのフォールバック応答で進行します。API呼び出しの制限を超過した可能性があります。`);
await updateStoryLog({ role: 'System', text: fallbackNarrative });
await updateStatus(fallbackStatus);
if (fallbackStatus.HP <= 0) {
await updateStoryLog({ role: 'GM', text: 'あなたは致命的な一撃を受け、その場で倒れた。ゲームオーバーだ。' });
}
}
};
}
}
// --- GM画面 ロジック (変更なし) ---
function setupMasterViewListeners(storyLog) {
const generateBtn = document.getElementById('generate-narrative-btn');
const input = document.getElementById('gm-narrative-input');
const promptRollBtn = document.getElementById('prompt-roll-btn');
if (promptRollBtn) {
promptRollBtn.onclick = () => {
input.value = "直前のプレイヤーの行動に対し、D100ロールによる判定を促し、その結果に基づいて物語の展開を記述せよ。";
showModal("判定ロールを促す定型文が入力されました。必要に応じて編集してください。");
};
}
// 既存のイベントリスナーを削除
if (generateBtn) generateBtn.onclick = null;
if (generateBtn) {
generateBtn.onclick = async () => {
const gmInstruction = input.value.trim();
if (!gmInstruction) { showModal("AIへの指示を入力してください。"); return; }
document.getElementById('loading').classList.remove('hidden');
// GM入力時はisMasterInput=trueで呼び出し、ステータス変更を行わないようAIに指示
const gmResponseObject = await generateNarrative(gmInstruction, storyLog, latestGameData?.playerStatus || initialData.playerStatus, true);
document.getElementById('loading').classList.add('hidden');
if (gmResponseObject && gmResponseObject.narrative) {
// GMモードではステータスはAIから更新しない(GMが手動で更新するため)
await updateStoryLog({ role: 'GM', text: gmResponseObject.narrative });
input.value = '';
} else {
showModal("GMナラティブの生成に失敗しました。AIからの応答が空か、または認証/API呼び出しエラーです。");
}
};
}
}
// --- 要約表示ロジック (NEW) ---
async function displaySummary(storyLog, currentStatus) {
const summaryDiv = document.getElementById('session-summary');
// 要約生成前のローディング表示
summaryDiv.innerHTML = `
<div class="flex items-center h-full text-fuchsia-400">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-fuchsia-400 mr-3"></div>
要約データをAIが生成中...
</div>`;
const summary = await generateSummary(storyLog, currentStatus);
if (summary && summary.currentLocation) {
summaryDiv.innerHTML = `
<div class="space-y-3 text-gray-300">
<p class="font-bold">現在の場所:</p>
<p class="pl-4 text-white">${summary.currentLocation}</p>
<p class="font-bold">直近の出来事:</p>
<p class="pl-4 text-white">${summary.latestConflict}</p>
<p class="font-bold">プレイヤー状態:</p>
<p class="pl-4 text-white">${summary.playerCondition}</p>
</div>
`;
} else {
summaryDiv.innerHTML = '<p class="text-red-400">要約の生成に失敗しました。ログが不足しているか、AI応答にエラーがありました。</p>';
}
}
// --- Gemini API 連携 ロジック ---
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`;
// AIの応答スキーマ定義 (ナラティブ生成用)
const narrativeResponseSchema = {
type: "OBJECT",
properties: {
narrative: { type: "STRING", description: "GMとして生成する物語の展開。" },
updatedStatus: {
type: "OBJECT",
description: "更新されたプレイヤーのステータス。HPが0になった場合は必ず「ゲームオーバーだ。」という文章をナラティブに含めること。",
properties: {
HP: { type: "NUMBER" },
MP: { type: "NUMBER" },
'経験値': { type: "NUMBER" },
'所持品': { "type": "STRING" }
},
required: ["HP", "MP", "経験値", "所持品"]
}
},
required: ["narrative"],
};
async function generateNarrative(gmInstruction, storyLog, currentStatus, isMasterInput = false) {
const context = storyLog.slice(-10).map(e => `${e.role}: ${e.text}`).join('\n'); // ログを最新10件に制限
const statusInstruction = isMasterInput
? "GMからの指示によるナラティブのみを生成し、updatedStatusオブジェクトは含めないでください。この場合、responseSchemaのupdatedStatusは無視してください。"
: "プレイヤーの行動の結果として、必ずupdatedStatusオブジェクトを含め、HP/MPの増減を反映させてください。HPが0以下になった場合、必ずナラティブに「ゲームオーバーだ。」という文章を加えてください。";
const systemPrompt = `
あなたはファンタジーTRPGのゲームマスター(GM)です。以下の制約に従って応答を生成してください。
**GMの役割と制約**
1. 応答は必ずJSON形式で、ナラティブを含めること。
2. ログ内の 'Dice' エントリの結果(例: 【1D6 ロール (結果: 5)】)を最優先で参照し、その目に基づいて判定を行うこと。
3. ${statusInstruction}
**現在のプレイヤー情報**
${JSON.stringify(currentStatus, null, 2)}
`;
const userQuery = `
--- 直近のストーリーログ ---
${context}
--- GMからのAIへの具体的な指示 ---
${gmInstruction}
`;
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
responseSchema: narrativeResponseSchema
}
};
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
if (response.status === 401) throw new Error(`認証エラー (401): APIキーが正しくありません。`);
if (response.status === 429 && i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw new Error(`APIエラー: ${response.status} ${response.statusText}`);
}
const result = await response.json();
const jsonString = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (jsonString) {
try {
const gmResponse = JSON.parse(jsonString);
if (isMasterInput) {
if (gmResponse.narrative) {
return { narrative: gmResponse.narrative };
}
} else {
if (gmResponse.narrative && gmResponse.updatedStatus) {
return gmResponse;
}
}
throw new Error("AI応答のJSON構造が不完全です。");
} catch (e) {
console.error("AI応答のJSONパースまたは構造検証エラー:", e, "Raw Text:", jsonString);
return null;
}
} else {
console.error("AIからの応答が空でした:", result);
return null;
}
} catch (error) {
console.error(`API呼び出し試行 ${i + 1}/${maxRetries} でエラーが発生しました:`, error.message);
if (i < maxRetries - 1) {
if (error.message.includes("401")) {
break;
}
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
} else {
console.error("API呼び出しが最大リトライ回数に達しました。");
return null;
}
}
}
return null;
}
// --- セッション要約生成ロジック (NEW) ---
async function generateSummary(storyLog, currentStatus) {
const summarySchema = {
type: "OBJECT",
properties: {
currentLocation: { type: "STRING", description: "プレイヤーが現在いる場所や場面。" },
latestConflict: { type: "STRING", description: "直前の主要な出来事や戦闘、または解決すべき問題。" },
playerCondition: { type: "STRING", description: "プレイヤーのHP/MP、重要な所持品、精神状態など。" }
},
required: ["currentLocation", "latestConflict", "playerCondition"]
};
const systemPrompt = `
あなたはTRPGセッションのログを分析するアシスタントです。GMの引き継ぎのために、直近の展開とプレイヤーの状況を構造化されたJSON形式で要約してください。
制約: 記述は簡潔で、GMがすぐに状況を把握できるようにしてください。
`;
// 最新20件のログと現在のステータスをコンテキストとして提供
const context = storyLog.slice(-20).map(e => `${e.role}: ${e.text}`).join('\n');
const userQuery = `
--- 直近のストーリーログ ---
${context}
--- 現在のステータス ---
${JSON.stringify(currentStatus, null, 2)}
上記の情報を分析し、JSONスキーマに従って要約を生成してください。
`;
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
responseSchema: summarySchema
}
};
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
if (response.status === 429 && i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw new Error(`APIエラー: ${response.status} ${response.statusText}`);
}
const result = await response.json();
const jsonString = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {
console.error("要約JSONパースエラー:", e, "Raw Text:", jsonString);
return null;
}
}
return null;
} catch (error) {
console.error(`要約API呼び出し試行 ${i + 1}/${maxRetries} でエラーが発生しました:`, error.message);
}
}
return null;
}
// --- モーダル表示関数 (変更なし) ---
function showModal(message) {
const modal = document.getElementById('custom-modal');
document.getElementById('modal-text').textContent = message;
modal.classList.remove('hidden');
}
document.getElementById('modal-close').addEventListener('click', () => {
document.getElementById('custom-modal').classList.add('hidden');
});
// 初回ロード時の処理
if (firebaseConfig) {
authenticate();
} else {
// Firebase設定がない場合のフォールバック(デバッグ用)
console.error("Firebase設定が見つかりません。データは永続化されず、AI機能も動作しません。");
document.getElementById('main-view').innerHTML = '<div class="text-red-400 p-4">Firebase設定が見つかりません。データは永続化されず、AI機能は動作しません。</div>';
latestGameData = initialData;
renderGame(initialData);
}
</script>
</body>
</html>
V0.9β(古い)(混乱中の為に作者用の復活の呪文)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI-GM TRPGシステム</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Inter', sans-serif; }
.log-container { max-height: 40vh; overflow-y: auto; scroll-behavior: smooth; }
.btn-inactive { background-color: #4b5563; color: #d1d5db; }
.btn-inactive:hover { background-color: #374151; }
.btn-player-active { background-color: #14b8a6; color: #1f2937; }
.btn-master-active { background-color: #e879f9; color: #1f2937; }
.btn-intervene { background-color: #f59e0b; color: #1f2937; }
.btn-intervene:hover { background-color: #d97706; }
.btn-roll-dice { background-color: #3b82f6; color: #ffffff; }
.btn-roll-dice:hover { background-color: #2563eb; }
.dice-result { font-size: 1.1rem; font-weight: bold; color: #fcd34d; text-shadow: 1px 1px 2px #000; }
.game-over-message { background-color: #dc2626; padding: 1rem; border-radius: 0.5rem; margin-top: 1rem; font-weight: bold; text-align: center; }
</style>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen p-4 md:p-8">
<div id="app" class="max-w-6xl mx-auto bg-gray-800 shadow-2xl rounded-xl p-6 md:p-8">
<h1 class="text-3xl font-bold text-teal-400 mb-6 border-b border-teal-500/50 pb-2">
AI-GM TRPGシステム
</h1>
<div id="auth-info" class="mb-4 text-sm text-gray-400">
<p><strong>セッションID:</strong> <span id="user-id">...</span></p>
</div>
<div class="flex space-x-2 mb-6">
<button id="player-view-btn" class="px-4 py-2 rounded-lg font-semibold transition-all shadow-md focus:outline-none focus:ring-4 focus:ring-teal-500/50 btn-player-active">
プレイヤー画面
</button>
<button id="master-view-btn" class="px-4 py-2 rounded-lg font-semibold transition-all shadow-md focus:outline-none focus:ring-4 focus:ring-fuchsia-500/50 btn-inactive">
GM画面
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div id="main-view" class="lg:col-span-2 bg-gray-700 p-6 rounded-xl shadow-inner min-h-[50vh]">
</div>
<div class="lg:col-span-1 bg-gray-700 p-6 rounded-xl shadow-inner">
<h2 class="text-xl font-bold text-orange-400 mb-4 border-b border-orange-500/50 pb-2">
キャラクター・ステータス
</h2>
<div id="status-display"></div>
<div id="gm-status-editor" class="mt-6 hidden">
<h3 class="text-lg font-bold text-orange-400 mb-3">ステータス編集 (GM専用)</h3>
<textarea id="status-json-input" rows="8" class="w-full p-3 text-sm rounded-lg bg-gray-800 text-gray-100 border border-gray-600 focus:ring-orange-500 focus:border-orange-500" placeholder='{"HP": 100, "MP": 50, "経験値": 0, "所持品": "木剣、粗末な布服"}'></textarea>
<button id="update-status-btn" class="w-full mt-3 px-4 py-2 rounded-lg font-semibold bg-orange-600 hover:bg-orange-700 transition-colors shadow-md">
ステータス更新
</button>
<p id="status-message" class="mt-2 text-sm text-red-400"></p>
</div>
</div>
</div>
<div id="loading" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="text-white text-xl flex flex-col items-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-500 mb-3"></div>
ゲームマスターが思考中...
</div>
</div>
<div id="custom-modal" class="fixed inset-0 bg-gray-900/80 flex items-center justify-center hidden z-50">
<div class="bg-gray-700 p-6 rounded-xl shadow-2xl max-w-sm w-full text-center">
<p id="modal-text" class="text-lg text-gray-100 mb-4"></p>
<button id="modal-close" class="px-6 py-2 rounded-lg font-semibold bg-teal-600 hover:bg-teal-700 transition-colors">閉じる</button>
</div>
</div>
</div>
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, getDoc, setDoc, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// setLogLevel('Debug'); // デバッグが必要な場合はコメントを解除
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : null;
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
let db, auth;
let userId = 'loading';
let currentView = 'player';
let latestGameData = null;
const SESSION_COLLECTION = 'gm_game_sessions';
const initialData = {
storyLog: [{ role: 'GM', text: 'ようこそ。あなたの冒険が始まります。目の前には古びた洞窟の入り口があります。どうしますか?' }],
playerStatus: { HP: 100, MP: 50, '経験値': 0, '所持品': '木剣、粗末な布服' },
lastUpdate: new Date(),
};
if (firebaseConfig) {
try {
const app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
} catch (error) {
console.error("Firebaseの初期化に失敗しました:", error);
showModal("システムエラー: Firebaseの初期化に失敗しました。");
}
}
async function authenticate() {
try {
if (auth) {
if (initialAuthToken) {
await signInWithCustomToken(auth, initialAuthToken);
} else {
await signInAnonymously(auth);
}
}
} catch (error) {
console.error("認証エラー:", error);
showModal("認証に失敗しました。");
}
}
onAuthStateChanged(auth, (user) => {
if (user) {
userId = user.uid;
document.getElementById('user-id').textContent = userId;
setupFirestoreListener();
} else {
authenticate();
}
});
function getSessionDocRef() {
return doc(db, 'artifacts', appId, 'public', 'data', SESSION_COLLECTION, userId);
}
async function initializeSession() {
try {
if (!db) return;
const docSnap = await getDoc(getSessionDocRef());
if (!docSnap.exists()) {
await setDoc(getSessionDocRef(), initialData, { merge: true });
}
} catch (error) {
console.error("セッションの初期化に失敗しました:", error);
showModal("セッション初期化エラー: データを保存できませんでした。");
}
}
function setupFirestoreListener() {
if (!db) return;
const docRef = getSessionDocRef();
initializeSession();
onSnapshot(docRef, (docSnap) => {
if (docSnap.exists()) {
const data = docSnap.data();
latestGameData = data;
renderGame(data);
} else {
initializeSession();
}
}, (error) => {
console.error("Firestoreリスナーエラー:", error);
showModal("データ同期エラーが発生しました。");
});
}
async function updateStoryLog(newEntry) {
if (!db) return null;
const docRef = getSessionDocRef();
try {
const docSnap = await getDoc(docRef);
const currentData = docSnap.data() || initialData;
const updatedLog = [...currentData.storyLog, newEntry];
await setDoc(docRef, {
storyLog: updatedLog,
lastUpdate: new Date()
}, { merge: true });
return updatedLog;
} catch (error) {
console.error("ログの更新に失敗しました:", error);
showModal("ログ更新エラー: データを保存できませんでした。");
return null;
}
}
async function updateStatus(newStatus) {
if (!db) return false;
const docRef = getSessionDocRef();
try {
await setDoc(docRef, {
playerStatus: newStatus,
lastUpdate: new Date()
}, { merge: true });
return true;
} catch (error) {
console.error("ステータスの更新に失敗しました:", error);
showModal("ステータス更新エラー: データを保存できませんでした。");
return false;
}
}
function renderGame(data) {
const mainView = document.getElementById('main-view');
const playerBtn = document.getElementById('player-view-btn');
const masterBtn = document.getElementById('master-view-btn');
const logContainer = createLogContainer(data.storyLog);
const isGameOver = data.playerStatus.HP <= 0;
let gameOverMessage = isGameOver ? '<div class="game-over-message text-2xl text-white">ゲームオーバー</div>' : '';
const diceRollSection = `
<div class="mb-4 p-4 bg-gray-600 rounded-xl shadow-lg ${isGameOver ? 'opacity-50 pointer-events-none' : ''}">
<h3 class="text-lg font-semibold text-white mb-3 border-b border-gray-500 pb-2">ダイスロール</h3>
<div class="flex flex-col space-y-3">
<!-- D100 ロール -->
<button data-dice="100" class="w-full px-6 py-2 rounded-lg font-bold btn-roll-dice transition-colors shadow-md flex items-center justify-center">
<span class="text-xl mr-2">🎲</span> D100 (パーセンテージ) ロール
</button>
<!-- カスタム ロール -->
<div class="flex space-x-2">
<input type="text" id="custom-dice-input" placeholder="例: 2D6+3" class="flex-grow p-2 rounded-lg bg-gray-800 text-gray-100 border border-gray-500 focus:ring-blue-500 focus:border-blue-500 text-sm">
<button data-dice="custom" class="flex-shrink-0 px-4 py-2 rounded-lg font-bold btn-roll-dice transition-colors shadow-md">
カスタムロール
</button>
</div>
<!-- D6 ロール -->
<button data-dice="6" class="w-full px-6 py-2 rounded-lg font-bold bg-gray-500 hover:bg-gray-600 transition-colors shadow-md flex items-center justify-center">
<span class="text-lg mr-2">🔹</span> D6 ロール
</button>
</div>
</div>
`;
renderStatusDisplay(data.playerStatus);
if (currentView === 'player') {
mainView.innerHTML = gameOverMessage + diceRollSection + renderPlayerView(logContainer, isGameOver);
setupPlayerViewListeners(data.storyLog, data.playerStatus);
document.getElementById('gm-status-editor').classList.add('hidden');
playerBtn.classList.remove('btn-inactive'); playerBtn.classList.add('btn-player-active');
masterBtn.classList.remove('btn-master-active'); masterBtn.classList.add('btn-inactive');
} else {
mainView.innerHTML = gameOverMessage + diceRollSection + renderMasterView(logContainer);
setupMasterViewListeners(data.storyLog);
document.getElementById('gm-status-editor').classList.remove('hidden');
masterBtn.classList.remove('btn-inactive'); masterBtn.classList.add('btn-master-active');
playerBtn.classList.remove('btn-player-active'); playerBtn.classList.add('btn-inactive');
}
setupDiceRollListener(isGameOver);
document.getElementById('status-json-input').value = JSON.stringify(data.playerStatus, null, 2);
setTimeout(() => {
const scrollable = document.querySelector('.log-container');
if (scrollable) scrollable.scrollTop = scrollable.scrollHeight;
}, 100);
}
function createLogContainer(storyLog) {
let logHTML = storyLog.map(entry => {
const isGM = entry.role === 'GM';
const isDice = entry.role === 'Dice';
const isSystem = entry.role === 'System';
let roleColor = isGM ? 'text-green-400' : isDice ? 'text-yellow-400' : isSystem ? 'text-red-400' : 'text-blue-400';
let bgColor = isGM ? 'bg-gray-800' : isDice ? 'bg-gray-800 border border-yellow-500/50' : isSystem ? 'bg-gray-800 border border-red-500/50' : 'bg-gray-700';
let align = isGM || isSystem ? 'text-left' : isDice ? 'text-center' : 'text-right';
return `
<div class="mb-3 ${align}">
<div class="${bgColor} inline-block p-3 rounded-xl shadow-md max-w-full lg:max-w-[70%]">
<span class="font-bold text-sm ${roleColor}">${entry.role}:</span>
<p class="mt-1 whitespace-pre-wrap text-gray-200 ${isDice ? 'dice-result' : ''}">${entry.text}</p>
</div>
</div>
`;
}).join('');
return `<div class="log-container space-y-3 p-2 bg-gray-900 rounded-lg border border-gray-600">${logHTML}</div>`;
}
function renderStatusDisplay(status) {
const display = document.getElementById('status-display');
let html = '';
for (const [key, value] of Object.entries(status)) {
let displayValue = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : String(value);
if (key === 'HP' || key === 'MP') {
const maxVal = 100; // 仮の最大値
const color = key === 'HP' ? (value <= 20 ? 'bg-red-500' : 'bg-green-500') : 'bg-blue-500';
const percentage = Math.max(0, Math.min(100, (value / maxVal) * 100));
html += `
<div class="mb-2">
<p class="text-sm text-gray-300 mb-1">${key}: <span class="font-semibold text-white">${displayValue}</span></p>
<div class="w-full bg-gray-600 rounded-full h-2">
<div class="${color} h-2 rounded-full transition-all duration-500" style="width: ${percentage}%;"></div>
</div>
</div>
`;
} else {
html += `<p class="text-sm text-gray-300">${key}: <span class="font-semibold text-white whitespace-pre-wrap">${displayValue}</span></p>`;
}
}
display.innerHTML = html;
}
function renderPlayerView(logContainer, isGameOver) {
const disabledClass = isGameOver ? 'opacity-50 pointer-events-none' : '';
return `
<h2 class="text-2xl font-bold text-teal-400 mb-4">冒険の記録 (プレイヤー)</h2>
${logContainer}
<div class="mt-4 ${disabledClass}">
<h3 class="font-bold text-lg mb-2">あなたの行動</h3>
<textarea id="player-action-input" rows="4" class="w-full p-3 rounded-lg bg-gray-800 text-gray-100 border border-teal-500 focus:ring-teal-500 focus:border-teal-500 resize-none" placeholder="${isGameOver ? 'ゲームオーバーです。新しい冒険を始めるにはGM画面でステータスをリセットしてください。' : '洞窟に入ります、剣を抜きます、など'}" ${isGameOver ? 'disabled' : ''}></textarea>
<button id="send-action-btn" class="w-full mt-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-teal-400 hover:bg-teal-300 transition-colors shadow-lg">
行動を宣言し、GMの応答を待つ
</button>
<button id="intervene-btn" class="w-full mt-4 px-4 py-3 rounded-lg font-bold text-gray-900 btn-intervene hover:opacity-90 transition-opacity shadow-lg">
GMとして状況に介入する/ステータスを編集する
</button>
</div>
`;
}
function renderMasterView(logContainer) {
return `
<h2 class="text-2xl font-bold text-fuchsia-400 mb-4">セッション管理 (ゲームマスター)</h2>
${logContainer}
<div class="mt-4">
<h3 class="font-bold text-lg mb-2">GMナラティブ生成</h3>
<button id="prompt-roll-btn" class="w-full mt-2 mb-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-purple-400 hover:bg-purple-300 transition-colors shadow-lg">
判定ロールを促す指示を入力
</button>
<textarea id="gm-narrative-input" rows="6" class="w-full p-3 rounded-lg bg-gray-800 text-gray-100 border border-fuchsia-500 focus:ring-fuchsia-500 focus:border-fuchsia-500 resize-none" placeholder="AIへの指示内容を入力します。例:プレイヤーを絶体絶命の危機に陥らせろ。"></textarea>
<button id="generate-narrative-btn" class="w-full mt-3 px-4 py-3 rounded-lg font-bold text-gray-900 bg-fuchsia-400 hover:bg-fuchsia-300 transition-colors shadow-lg">
AI-GMに展開を生成させる
</button>
</div>
`;
}
function switchView(viewName) {
currentView = viewName;
if (latestGameData) {
renderGame(latestGameData);
}
}
document.getElementById('player-view-btn').addEventListener('click', () => switchView('player'));
document.getElementById('master-view-btn').addEventListener('click', () => switchView('master'));
// --- ダイスロール ロジック ---
function parseDiceString(diceString) {
const match = diceString.match(/^(\d*)D(\d+)(?:\s*([+-])\s*(\d+))?$/i);
if (match) {
const numDice = parseInt(match[1] || '1', 10);
const sides = parseInt(match[2], 10);
const sign = match[3] || '+';
const modifier = parseInt(match[4] || '0', 10) * (sign === '-' ? -1 : 1);
// 実行可能な制限
if (numDice > 100 || sides > 1000) return null;
return { numDice, sides, modifier };
}
return null;
}
function rollDice(numDice, sides, modifier = 0) {
let total = 0;
const rolls = [];
for (let i = 0; i < numDice; i++) {
const roll = Math.floor(Math.random() * sides) + 1;
rolls.push(roll);
total += roll;
}
const finalResult = total + modifier;
return { rolls, total, finalResult, modifier };
}
function setupDiceRollListener(isGameOver) {
const rollBtns = document.querySelectorAll('[data-dice]');
const customInput = document.getElementById('custom-dice-input');
rollBtns.forEach(btn => {
if (!isGameOver) {
btn.onclick = async (e) => {
const diceType = e.currentTarget.dataset.dice;
let rollResult = null;
let rollString = '';
if (diceType === '6') {
rollString = '1D6';
rollResult = rollDice(1, 6);
} else if (diceType === '100') {
rollString = 'D100';
rollResult = rollDice(1, 100);
} else if (diceType === 'custom') {
const customStr = customInput.value.trim();
const parsed = parseDiceString(customStr);
if (parsed) {
rollString = `${parsed.numDice}D${parsed.sides}${parsed.modifier !== 0 ? (parsed.modifier > 0 ? '+' : '') + parsed.modifier : ''}`;
rollResult = rollDice(parsed.numDice, parsed.sides, parsed.modifier);
customInput.value = ''; // 成功したらクリア
} else {
showModal("カスタムダイスの形式が不正です。例: 2D6+3, D100, 3D8");
return;
}
}
if (rollResult) {
const { rolls, total, finalResult, modifier } = rollResult;
let message = `【${rollString} ロール (結果: ${finalResult})】\n`;
if (rolls.length > 1) {
message += `個別出目: [${rolls.join(', ')}] = ${total}`;
if (modifier !== 0) {
message += ` ${modifier > 0 ? '+' : ''}${modifier} (修正値)`;
}
message += `\n**最終結果: ${finalResult}**`;
} else if (modifier !== 0) {
message += `出目: ${total} ${modifier > 0 ? '+' : ''}${modifier} (修正値)\n**最終結果: ${finalResult}**`;
} else {
message += `出目: ${finalResult}`;
}
if (diceType === '100') {
message += finalResult <= 5 ? ' (クリティカル!) - 成功' : finalResult >= 96 ? ' (ファンブル...) - 失敗' : '';
}
await updateStoryLog({ role: 'Dice', text: message });
}
};
}
});
}
// --- ステータス編集 ロジック ---
document.getElementById('update-status-btn').addEventListener('click', () => {
const jsonInput = document.getElementById('status-json-input').value;
const statusMsg = document.getElementById('status-message');
statusMsg.textContent = '';
try {
const newStatus = JSON.parse(jsonInput);
// 必須フィールドの簡易チェック
if (typeof newStatus !== 'object' || newStatus === null || typeof newStatus.HP !== 'number') {
throw new Error("JSON形式が不正、またはHPフィールドが数値ではありません。");
}
updateStatus(newStatus);
statusMsg.textContent = 'ステータスを更新しました!';
statusMsg.classList.remove('text-red-400');
statusMsg.classList.add('text-green-400');
} catch (e) {
statusMsg.textContent = 'JSON形式が不正です: ' + e.message;
statusMsg.classList.remove('text-green-400');
statusMsg.classList.add('text-red-400');
}
});
// --- プレイヤー画面 ロジック ---
function setupPlayerViewListeners(storyLog, currentStatus) {
const sendBtn = document.getElementById('send-action-btn');
const interveneBtn = document.getElementById('intervene-btn');
const isGameOver = currentStatus.HP <= 0;
if (interveneBtn) interveneBtn.onclick = () => switchView('master');
if (sendBtn && !isGameOver) {
sendBtn.onclick = async () => {
const input = document.getElementById('player-action-input');
const action = input.value.trim();
if (!action) { showModal("プレイヤーの行動を入力してください。"); return; }
await updateStoryLog({ role: 'Player', text: action });
input.value = '';
document.getElementById('loading').classList.remove('hidden');
const gmInstruction = "直前のプレイヤーの行動をGMとして処理し、その結果(成功/失敗判定、または単なる描写)と次の展開を記述せよ。もし直前のログにダイスロール結果があれば、その結果に基づいて判定を行うこと。結果に応じて、現在のステータスを更新(HP/MPの増減)し、必ずJSON形式でナラティブと更新後のステータスを返却せよ。";
const fullLog = [...storyLog, { role: 'Player', text: action }];
const gmResponseObject = await generateNarrative(gmInstruction, fullLog, currentStatus, false);
document.getElementById('loading').classList.add('hidden');
if (gmResponseObject && gmResponseObject.narrative) {
const { narrative, updatedStatus } = gmResponseObject;
await updateStoryLog({ role: 'GM', text: narrative });
await updateStatus(updatedStatus);
if (updatedStatus.HP <= 0 && !narrative.includes("ゲームオーバー")) {
await updateStoryLog({ role: 'GM', text: 'あなたは致命的な一撃を受け、その場で倒れた。ゲームオーバーだ。' });
}
} else {
// **フォールバック処理: APIエラー発生時の代替応答**
const fallbackNarrative = `【GM介入 (システム応答)】あなたの行動「${action}」により、物語が進行します。AI-GMへの接続に失敗したため、システムが暫定的に処理を行います。あなたは運良く洞窟の奥へと無傷で進むことができましたが、少し疲労しました。`;
const fallbackStatus = {
...currentStatus,
HP: currentStatus.HP > 5 ? currentStatus.HP - 5 : 0,
MP: currentStatus.MP
};
showModal(`GMの応答を生成できませんでした。システムのフォールバック応答で進行します。API呼び出しの制限を超過した可能性があります。`);
await updateStoryLog({ role: 'System', text: fallbackNarrative });
await updateStatus(fallbackStatus);
if (fallbackStatus.HP <= 0) {
await updateStoryLog({ role: 'GM', text: 'あなたは致命的な一撃を受け、その場で倒れた。ゲームオーバーだ。' });
}
}
};
}
}
// --- GM画面 ロジック ---
function setupMasterViewListeners(storyLog) {
const generateBtn = document.getElementById('generate-narrative-btn');
const input = document.getElementById('gm-narrative-input');
const promptRollBtn = document.getElementById('prompt-roll-btn');
if (promptRollBtn) {
promptRollBtn.onclick = () => {
input.value = "直前のプレイヤーの行動に対し、D100ロールによる判定を促し、その結果に基づいて物語の展開を記述せよ。";
showModal("判定ロールを促す定型文が入力されました。必要に応じて編集してください。");
};
}
if (generateBtn) {
generateBtn.onclick = async () => {
const gmInstruction = input.value.trim();
if (!gmInstruction) { showModal("AIへの指示を入力してください。"); return; }
document.getElementById('loading').classList.remove('hidden');
// GM入力時はisMasterInput=trueで呼び出し、ステータス変更を行わないようAIに指示
const gmResponseObject = await generateNarrative(gmInstruction, storyLog, latestGameData?.playerStatus || initialData.playerStatus, true);
document.getElementById('loading').classList.add('hidden');
if (gmResponseObject && gmResponseObject.narrative) {
await updateStoryLog({ role: 'GM', text: gmResponseObject.narrative });
input.value = '';
} else {
showModal("GMナラティブの生成に失敗しました。AIからの応答が空か、または認証/API呼び出しエラーです。");
}
};
}
}
// --- Gemini API 連携 ロジック ---
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`;
const responseSchema = {
type: "OBJECT",
properties: {
narrative: { type: "STRING", description: "GMとして生成する物語の展開。" },
// updatedStatusはプレイヤー入力時のみ必須とするため、ここでは必須にしない。
updatedStatus: {
type: "OBJECT",
description: "更新されたプレイヤーのステータス。",
properties: {
HP: { type: "NUMBER" },
MP: { type: "NUMBER" },
'経験値': { type: "NUMBER" },
'所持品': { "type": "STRING" }
},
required: ["HP", "MP", "経験値", "所持品"]
}
},
required: ["narrative"],
};
async function generateNarrative(gmInstruction, storyLog, currentStatus, isMasterInput = false) {
const context = storyLog.slice(-10).map(e => `${e.role}: ${e.text}`).join('\n'); // ログを最新10件に制限
const statusInstruction = isMasterInput
? "GMからの指示によるナラティブのみを生成し、updatedStatusオブジェクトは含めないでください。"
: "プレイヤーの行動の結果として、必ずupdatedStatusオブジェクトを含め、HP/MPの増減を反映させてください。HPが0以下になった場合、必ずナラティブに「ゲームオーバーだ。」という文章を加えてください。";
const systemPrompt = `
あなたはファンタジーTRPGのゲームマスター(GM)です。以下の制約に従って応答を生成してください。
**GMの役割と制約**
1. 応答は必ずJSON形式で、ナラティブを含めること。
2. ログ内の 'Dice' エントリの結果(例: 【1D6 ロール (結果: 5)】)を最優先で参照し、その目に基づいて判定を行うこと。
3. ${statusInstruction}
**現在のプレイヤー情報**
${JSON.stringify(currentStatus, null, 2)}
`;
const userQuery = `
--- 直近のストーリーログ ---
${context}
--- GMからのAIへの具体的な指示 ---
${gmInstruction}
`;
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
// Google Search groundingはTRPGナラティブ生成では不要のため省略(今回の用途では)
// tools: [{ "google_search": {} }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
responseSchema: responseSchema
}
};
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
if (response.status === 401) throw new Error(`認証エラー (401): APIキーが正しくありません。`);
if (response.status === 429 && i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw new Error(`APIエラー: ${response.status} ${response.statusText}`);
}
const result = await response.json();
const jsonString = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (jsonString) {
const parsed = JSON.parse(jsonString);
if (!parsed.narrative) {
throw new Error("応答JSONにnarrativeフィールドが不足しています。");
}
if (!isMasterInput) {
// プレイヤー入力時: updatedStatusの存在を確認
if (!parsed.updatedStatus) {
throw new Error("応答JSONにupdatedStatusオブジェクトが不足しています。");
}
return parsed; // narrativeとupdatedStatusを返す
} else {
// GM入力時: narrativeのみでOK。statusは現在のものを維持。
return { narrative: parsed.narrative, updatedStatus: currentStatus };
}
} else {
console.error("API応答にJSON文字列が見つかりません:", JSON.stringify(result));
return null;
}
} catch (error) {
console.error(`Gemini API呼び出しエラー (試行 ${i + 1}/${maxRetries}):`, error);
if (i === maxRetries - 1) return null;
}
}
return null;
}
// --- モーダル表示 ロジック ---
function showModal(message) {
document.getElementById('modal-text').textContent = message;
document.getElementById('custom-modal').classList.remove('hidden');
}
document.getElementById('modal-close').addEventListener('click', () => {
document.getElementById('custom-modal').classList.add('hidden');
});
</script>
</body>
</html>


おまけで派生発生した、AI物語ジェネレーターも置いときます。使い方は同様
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI物語ジェネレーター</title>
<!-- Tailwind CSS CDNを読み込み -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Interフォントを設定 */
html { font-family: 'Inter', sans-serif; }
/* カスタムスクロールバー(見やすいように) */
.story-output::-webkit-scrollbar { width: 8px; }
.story-output::-webkit-scrollbar-thumb { background-color: #a8a8a8; border-radius: 4px; }
.story-output::-webkit-scrollbar-track { background-color: #f1f1f1; }
</style>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'primary': '#4f46e5',
'secondary': '#8b5cf6',
},
}
}
}
</script>
</head>
<body class="bg-gray-50 flex flex-col min-h-screen">
<header class="bg-white shadow-md p-4 sticky top-0 z-10">
<div class="max-w-4xl mx-auto flex justify-between items-center">
<h1 class="text-2xl font-bold text-primary">AI物語ジェネレーター</h1>
<p class="text-sm text-gray-500">Google Gemini APIを使用</p>
</div>
</header>
<main class="flex-grow max-w-4xl mx-auto w-full p-4 space-y-8">
<!-- 入力フォームカード -->
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h2 class="text-xl font-semibold mb-4 text-gray-700">物語のテーマ設定</h2>
<div class="space-y-4">
<div>
<label for="themeInput" class="block text-sm font-medium text-gray-700 mb-1">物語のテーマや設定:</label>
<input type="text" id="themeInput" placeholder="例: 未来の探偵と喋る猫の冒険" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary transition duration-150" required>
</div>
<div>
<label for="styleSelect" class="block text-sm font-medium text-gray-700 mb-1">文体/ジャンル:</label>
<select id="styleSelect" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary transition duration-150">
<option value="ファンタジー小説">ファンタジー小説</option>
<option value="SF短編">SF短編</option>
<option value="歴史物語">歴史物語</option>
<option value="ホラー">ホラー</option>
<option value="子供向け絵本">子供向け絵本</option>
<option value="ミステリー">ミステリー</option>
</select>
</div>
<div>
<label for="lengthSelect" class="block text-sm font-medium text-gray-700 mb-1">長さの目安:</label>
<select id="lengthSelect" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary transition duration-150">
<option value="約300字">短め (約300字)</option>
<option value="約800字">標準 (約800字)</option>
<option value="約1500字">長め (約1500字)</option>
</select>
</div>
<button id="generateBtn" class="w-full bg-primary hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-lg transition duration-300 shadow-md flex items-center justify-center space-x-2 disabled:bg-gray-400" disabled>
<svg id="generateIcon" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zm-3.536 3.536l-9.192 9.192A2 2 0 002.19 18.337l9.192-9.192-2.828-2.828z" />
</svg>
<svg id="loadingSpinner" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>物語を生成</span>
</button>
</div>
</div>
<!-- 出力表示エリア -->
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 min-h-[400px]">
<h2 class="text-xl font-semibold mb-4 text-gray-700">生成された物語</h2>
<div id="statusMessage" class="text-sm text-gray-500 mb-4 hidden">準備完了。テーマを入力してください。</div>
<div id="errorMessage" class="text-sm text-red-500 mb-4 p-2 bg-red-50 border border-red-200 rounded hidden"></div>
<div id="storyOutput" class="story-output text-gray-800 whitespace-pre-wrap leading-relaxed h-80 overflow-y-auto p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300">
ここにAIが生成した物語が表示されます。
</div>
<!-- 引用表示エリア -->
<div id="sourcesContainer" class="mt-4 border-t pt-4 hidden">
<p class="text-sm font-medium text-gray-700 mb-2">参照元 (Google Search):</p>
<ul id="sourceList" class="list-disc ml-5 text-sm space-y-1"></ul>
</div>
</div>
</main>
<footer class="p-4 text-center text-sm text-gray-500 mt-8">
© 2024 AI Story Generator. All rights reserved.
</footer>
<script type="module">
// Firebase Authに必要なモジュールをインポート
import { getAuth, signInAnonymously, signInWithCustomToken } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
// グローバル変数
const apiKey = ""; // APIキーはここでは空にしておきます
const apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent";
// UI要素の取得
const themeInput = document.getElementById('themeInput');
const styleSelect = document.getElementById('styleSelect');
const lengthSelect = document.getElementById('lengthSelect');
const generateBtn = document.getElementById('generateBtn');
const generateIcon = document.getElementById('generateIcon');
const loadingSpinner = document.getElementById('loadingSpinner');
const storyOutput = document.getElementById('storyOutput');
const errorMessage = document.getElementById('errorMessage');
const statusMessage = document.getElementById('statusMessage');
const sourcesContainer = document.getElementById('sourcesContainer');
const sourceList = document.getElementById('sourceList');
// Firebase初期化 (アプリの実行環境で提供されるグローバル変数を使用)
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : {};
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
let db, auth;
let isAuthReady = false;
/**
* Exponential backoffを使用したフェッチ処理
* @param {string} url - API URL
* @param {object} options - Fetch options
* @param {number} retries - 最大リトライ回数
* @returns {Promise<Response>} - Fetch Response
*/
async function retryFetch(url, options, retries = 5) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, options);
if (response.status !== 429) { // 429 Too Many Requests以外はリトライせずに進む
return response;
}
console.warn(`Attempt ${i + 1} failed with 429, retrying...`);
} catch (error) {
// ネットワークエラーなど
console.error(`Attempt ${i + 1} failed with error:`, error);
}
if (i < retries - 1) {
const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('API呼び出しが最大リトライ回数を超えて失敗しました。');
}
/**
* Firebaseの認証を初期化する
*/
async function initFirebaseAndAuth() {
try {
if (Object.keys(firebaseConfig).length > 0) {
const app = initializeApp(firebaseConfig);
auth = getAuth(app);
if (initialAuthToken) {
await signInWithCustomToken(auth, initialAuthToken);
console.log("Custom token sign-in successful.");
} else {
await signInAnonymously(auth);
console.log("Anonymous sign-in successful.");
}
} else {
console.warn("Firebase configuration is missing.");
}
isAuthReady = true;
generateBtn.disabled = false;
statusMessage.textContent = "準備完了。テーマを入力してください。";
statusMessage.classList.remove('hidden');
} catch (error) {
console.error("Firebase Auth initialization failed:", error);
errorMessage.textContent = `認証エラーが発生しました: ${error.message}`;
errorMessage.classList.remove('hidden');
}
}
// 初期化
initFirebaseAndAuth();
// 入力フィールドの変更を監視し、ボタンの状態を更新
themeInput.addEventListener('input', () => {
generateBtn.disabled = !themeInput.value.trim() || !isAuthReady;
errorMessage.classList.add('hidden');
});
/**
* Gemini APIを呼び出して物語を生成する
* @param {string} userPrompt - ユーザーが設定したプロンプト
* @returns {Promise<{text: string, sources: Array<{uri: string, title: string}>}>} - 生成されたテキストと引用元
*/
async function generateNarrative(userPrompt) {
// システムプロンプトを設定
const systemPrompt = `あなたは世界的に有名な物語作家です。ユーザーの要望に基づき、指定された文体と長さで、深く感情移入できる、魅力的な物語を作成してください。出力は物語のテキストのみとし、前置きや後付けのコメント、タイトル、見出しは一切含めないでください。`;
// Google Search Groundingを有効にする
const tools = [{ "google_search": {} }];
const payload = {
contents: [{ parts: [{ text: userPrompt }] }],
tools: tools,
systemInstruction: { parts: [{ text: systemPrompt }] },
// 応答の温度を調整して創造性を高める
config: {
temperature: 0.8
}
};
const fullUrl = `${apiUrl}?key=${apiKey}`;
const response = await retryFetch(fullUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json();
console.error("API Error Response:", errorData);
throw new Error(`Gemini API呼び出しエラー: ${response.status} ${response.statusText}. 詳細: ${JSON.stringify(errorData.error)}`);
}
const result = await response.json();
const candidate = result.candidates?.[0];
if (candidate && candidate.content?.parts?.[0]?.text) {
// 1. 生成されたテキストを抽出
const text = candidate.content.parts[0].text;
// 2. 引用元を抽出 (グラウンディングが使用された場合)
let sources = [];
const groundingMetadata = candidate.groundingMetadata;
if (groundingMetadata && groundingMetadata.groundingAttributions) {
sources = groundingMetadata.groundingAttributions
.map(attribution => ({
uri: attribution.web?.uri,
title: attribution.web?.title,
}))
.filter(source => source.uri && source.title); // 有効なソースのみをフィルタ
}
return { text, sources };
} else {
// *** 修正箇所: テキスト応答がない場合のエラー処理を強化し、デバッグ情報をコンソールに出力する ***
console.error('Gemini API応答からテキストコンテンツが見つかりませんでした。');
const finishReason = candidate?.finishReason;
const safetyRatings = candidate?.safetyRatings;
if (finishReason) {
console.error(`Finish Reason: ${finishReason}`);
}
if (safetyRatings) {
console.error('Safety Ratings:', safetyRatings);
}
console.error('Full API Result:', result);
let errorMessageText = "AIからテキスト応答がありませんでした。";
if (finishReason === 'SAFETY') {
errorMessageText += " コンテンツフィルタリングによりブロックされた可能性があります。プロンプトを見直してください。";
} else if (finishReason) {
errorMessageText += ` 終了理由: ${finishReason}`;
} else {
errorMessageText += " 応答データ構造に問題がある可能性があります。";
}
throw new Error(errorMessageText);
}
}
// 生成ボタンのクリックイベント
generateBtn.onclick = async () => {
const theme = themeInput.value.trim();
const style = styleSelect.value;
const length = lengthSelect.value;
if (!theme) {
errorMessage.textContent = "テーマを入力してください。";
errorMessage.classList.remove('hidden');
return;
}
// UIを更新 (ローディング開始)
generateBtn.disabled = true;
generateIcon.classList.add('hidden');
loadingSpinner.classList.remove('hidden');
storyOutput.textContent = '物語を生成中...少々お待ちください。';
errorMessage.classList.add('hidden');
sourcesContainer.classList.add('hidden');
const fullPrompt = `テーマ: ${theme} / 文体/ジャンル: ${style} / 長さの目安: ${length}。この情報に基づいて物語を作成してください。`;
try {
const { text, sources } = await generateNarrative(fullPrompt);
storyOutput.textContent = text;
storyOutput.classList.remove('border-dashed');
// 引用元の表示
sourceList.innerHTML = '';
if (sources.length > 0) {
sources.forEach(source => {
const li = document.createElement('li');
li.innerHTML = `<a href="${source.uri}" target="_blank" class="text-primary hover:underline" rel="noopener noreferrer">${source.title}</a>`;
sourceList.appendChild(li);
});
sourcesContainer.classList.remove('hidden');
} else {
sourcesContainer.classList.add('hidden');
}
} catch (error) {
console.error("生成エラー:", error);
storyOutput.textContent = '物語の生成中にエラーが発生しました。';
errorMessage.textContent = `エラー: ${error.message}`;
errorMessage.classList.remove('hidden');
storyOutput.classList.add('border-dashed');
sourcesContainer.classList.add('hidden');
} finally {
// UIを更新 (ローディング終了)
generateBtn.disabled = !isAuthReady || !themeInput.value.trim();
loadingSpinner.classList.add('hidden');
generateIcon.classList.remove('hidden');
}
};
</script>
</body>
</html>
