大空の狼達 Ver1.1
これは大空の傭兵の物語
血みどろの戦争に小国が巻き込まれたら、パイロット等育成出来ない、そして、そこに傭兵が流れ込む。

外人部隊を取り仕切る女司令官…この戦いで失われるのは、彼女の心か、野望か、それとも命か?
大空を汚す金で雇われた者達の物語
死神との契約書にサインしちまったろくでなしだからこその矜持が死線の中にある。
まだ、シナリオ入れてないのでサンプル状態です。暇になったらイロイロネタ入れたい。
AIの自動ジェネレーターと世界背景を自分で入れる事で、カーディナルシステムみたいにシナリオを修正して構築してくれる柔軟な設計になってしまったのはとても面白いものになってしまった。
もう、シュミレーション作ってたらRPGなってたようなもんだがまあ、やりたいことが出来ればそれで良い(笑)
ゲームが作れますシリーズ、内容を注文したらこうなったバージョン。(笑)
イロイロキャンペーン妄想ネタ入れたい。
尚、多分育て直しになるけど、どうしても入れたい機体とか、これがいるだろ!とか、ミッションネタとか全部入れられないけどワイワイやってく予定です。
入れるとしたら、ファントム2とハリアーかもだけど、機体を生かすミッションがまだ無い。
※追伸:一応趣味でイロイロ入れてみた(B-1やB-52まで!)
今回はAIがランダムミッションを状況から考えるのであり得ると言えばあり得る(のか?)
なので『初代大戦略でやりそうなネタ』は大抵実現出来るようにしておいた。
敵国都市を絨毯爆撃したらそのあとどう判定されるかまではしらない。
(まだやったことないから)
展開次第で必要なら機体は調達出来るよ?
ここにもクレムリン宮殿まで買い付けしそうな奴がいるのかも知れない。
司令官とのブリーフィングでこの物語は何処まで脱線していくかはわかりません。
面白いのでわかる人にはわかる機体を小道具に用意しました。
世界設定に『イロイロ書き込んで』ミッションを発生させて楽しんだら良いかと。
『世界設定は途中からでも書き換えて可能』ですから、基地を乗っ取られて、空母に乗り換えて一夜城を作っても面白いかもしれませんし、山をくりぬいた秘密基地、孤島に基地を作ったり、潜水空母から発艦なんてのも面白いかと、やるかやらないかはあなたの妄想力次第です。
道具は用意しました、あとはあなたの架空戦記を描くと面白いかと。
取りあえず自分は中東の内戦の身内の争いで、基地司令と敵に寝返った父親との骨肉の争いというどっかで聞いたような設定で、エリア88っぽいネタで女司令官にこき使われてます。
さて、どんな結末になるやら。
やるかやらないかはあなた次第。
現実逃避してるとき程ネタが浮かぶ。
戦場は大空だけではなかった(笑)
スケジュールは進まないのに
こう言う余計なアイデアだけは
ポンポン浮かぶんだよなあ…。
方針迷走中のお知らせ
最初、シミュレーションを想定してましたが、記憶力問題が露見してきて逆に数値管理よりファジーなTRPG要素で戦場ドラマを演出した方がとかなってきて方向性がズレてきました。
元々やりたいことが叶えられれば何でもよいの結果オーライの成果物が望む形になってれば、取りあえず次に進むので、今はこんなテストです。
最終目標の為の実験データ取りを兼ねてるので完璧なものはまだ求めてません。
ただ、面白いアイデアはそのうち形にしておけば楽しむ人も居るだろう程度です。
現在七回ほど完成に近付いたらGemini3に積み木崩しされるという『やっぱりお前もかブルータス!』見たいになってます。
いや、格段に難易度高いことをさせてるから当然かなとは思うので未来待ちかもですが…。
ついにクロードの無料枠では一発オーバーの肥大化まで開発進んで崩壊するレベルまで妄想膨らんだので(結局お蔵入り)
AGI来たら完成すると思う。
なので、『いつの時代も出きる範囲で楽しむ』で、設計を我慢するしかなさそうです。
たぶん積み木の組み立て方が悪いので初期設計で全部伝えてこんなのがやりたいとAIが完璧にイメージ出きる程精度が上がるみたいなコツはわかってきました。
どうにも、司令官とラブラブモード機能を入れると崩壊するというのを繰り返してて、そりゃ戦場ものに似合わないからAIも方向性に混乱するみたいです(笑)
まあ、なのでどこに落ち着くかよくわかりませんが、そのうち形になるかも知れません。
司令官モードと機体を増やすと想定外らしくてエラーが出まくりで内部崩壊が始まります。
最後には全く別物なってるかもです、これ
皮肉な事に、ウクライナの大空が傭兵でないにしろこんな惨状で実験場になってます。
リアルは遊びじゃない非情さが、今進んでいて他人事と笑ってればそのうち巻き込まれるかも知れませんけどね。
数年前のウクライナの平和を訴えてた青年はロシアの人達にブーイングされていました。
双方、今、どうなって何を思うのだろう…。
ゲームの話で現実を語るのは不謹慎かもですが…。
少なくとも現実の大空も血で今も染まっています。
未来がどうなるかは、どんなことでも現実になるまでは予測でしかありません。
時間取れたらエースコンバットやりたいなあ
ゲーム4カ月遊んでないとか自分には無かった
異常事態、AI面白過ぎる。
御注意:現時点はテスト兼ねてますので
初代Wizardryモードです。
命大事に…。
シナリオモードになったら救済考えます。
動作テストなので
Gemini 、Canvasモードでの起動を想定しています。(テスト環境)
Canvasモードで張り付けてください。

V1.1 報酬が750$とか、傭兵にケチ過ぎるのが納得行かないので最低報酬を設定、あと、内部に司令官の設定データあるので、
『主人公が女性』、司令官を男性にしたい場合は修正してください。
まあ、なんとなくこれで、大体の物語の舞台と設定にして遊べる筈
エースコンバットの大体のシナリオは近未来兵器は対応出来ないので改造いるけど現代戦までならなんとか?みたいな感じですかね。
Canvasでの使用を想定しています。
尚、1.1以降は通信状態が悪いとリトライするようになってるので回線次第では起動まで時間がかかるかもです。エラーにならないだけマシかもですが…。
AIが判定を多方面に影響考えてジャッジしてくるので、日頃のAIとの接し方でシナリオは変わってくるかもです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大空の狼達 - Wolves of the Sky</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Interフォントをロード -->
<link href="https://fonts.css.com/css2?family=Inter:wght400;600;700&display=swap" rel="stylesheet">
<!-- Firebase SDKのインポート (コアサービスとAuthのみ) -->
<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, setLogLevel } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
setLogLevel('Debug');
window.firebase = {
db: null,
auth: null,
userId: null,
isAuthReady: false
};
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = JSON.parse(typeof __firebase_config !== 'undefined' ? __firebase_config : '{}');
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
if (Object.keys(firebaseConfig).length > 0) {
const app = initializeApp(firebaseConfig);
window.firebase.db = getFirestore(app);
window.firebase.auth = getAuth(app);
onAuthStateChanged(window.firebase.auth, async (user) => {
if (user) {
window.firebase.userId = user.uid;
} else {
if (initialAuthToken) {
try {
await signInWithCustomToken(window.firebase.auth, initialAuthToken);
} catch (error) {
console.error("Custom token sign-in failed:", error);
await signInAnonymously(window.firebase.auth);
}
} else {
await signInAnonymously(window.firebase.auth);
}
window.firebase.userId = window.firebase.auth.currentUser?.uid || crypto.randomUUID();
}
window.firebase.isAuthReady = true;
document.dispatchEvent(new CustomEvent('firebaseAuthReady'));
});
} else {
console.error("Firebase configuration is missing.");
}
</script>
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #0d1117;
color: #c9d1d9;
}
.scroll-container {
overflow-y: auto;
}
.scroll-container::-webkit-scrollbar {
width: 8px;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: #30363d;
border-radius: 4px;
}
.scroll-container::-webkit-scrollbar-track {
background: #161b22;
}
.chat-log {
height: 350px;
background-color: #161b22;
border-top: 2px solid #30363d;
}
.message-commander {
align-self: flex-start;
background-color: #213c32;
border-radius: 12px 12px 12px 0;
}
.message-pilot {
align-self: flex-end;
background-color: #293855;
border-radius: 12px 12px 0 12px;
}
</style>
</head>
<body class="p-4 sm:p-6 lg:p-8">
<div id="loading-overlay" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 transition-opacity duration-300 hidden">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto"></div>
<p class="mt-4 text-lg font-semibold text-green-400">通信中... ブリーフィングを受信しています</p>
</div>
</div>
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold mb-6 text-green-400 tracking-wider">大空の狼達 - Wolves of the Sky</h1>
<p id="user-id-display" class="text-sm text-gray-500 mb-4">ユーザーID: Loading...</p>
<!-- ステータス表示カード -->
<div class="bg-[#161b22] p-6 rounded-xl shadow-lg mb-6 border border-[#30363d] grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-400">パイロット名 (<span id="pilot-display">未設定</span>)</p>
<h2 id="pilot-name-display" class="text-2xl font-semibold text-white">---</h2>
<p class="text-sm text-gray-400 mt-2">司令官名 (<span id="commander-display">未設定</span>)</p>
<h2 id="commander-name-display" class="text-xl font-semibold text-gray-300">---</h2>
</div>
<div>
<p class="text-sm text-gray-400">現在の機体</p>
<h2 id="aircraft-display" class="text-3xl font-bold text-yellow-300">A-4 Skyhawk</h2>
<div class="mt-2 text-lg">
<p>資金: <span id="money-display" class="font-bold text-green-500">0</span> $</p>
<p>経験値: <span id="xp-display" class="font-bold text-blue-400">0</span> XP</p>
</div>
</div>
</div>
<!-- アクションと設定セクション -->
<div class="mb-6 grid grid-cols-1 sm:grid-cols-4 gap-4">
<button id="start-mission-btn" class="col-span-2 sm:col-span-2 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
<i class="fas fa-satellite-dish"></i> ブリーフィングを表示
</button>
<button id="confirm-mission-btn" class="col-span-2 sm:col-span-2 bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
<i class="fas fa-fighter-jet"></i> ミッション開始
</button>
<button id="name-config-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200">
名前・設定
</button>
<button id="hangar-btn" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
格納庫 (機体交換)
</button>
<button id="final-mission-btn" class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50 col-span-2" disabled>
最終目標へ
</button>
</div>
<!-- ブリーフィング&チャットエリア -->
<div id="mission-briefing-area" class="bg-[#161b22] p-6 rounded-xl shadow-lg border border-[#30363d] mb-6 hidden">
<h2 id="briefing-title" class="text-2xl font-bold mb-3 text-yellow-400">ミッション・タイトル</h2>
<div id="briefing-content" class="text-gray-300 whitespace-pre-wrap mb-4 border-b border-[#30363d] pb-4"></div>
<h3 class="text-xl font-semibold mb-3 text-white">司令官とのチャット</h3>
<div id="chat-log" class="chat-log scroll-container p-3 flex flex-col space-y-3"></div>
<div id="chat-container" class="mt-4 flex space-x-2">
<input type="text" id="chat-input" class="flex-grow p-3 rounded-lg bg-[#30363d] border border-[#444c56] text-white focus:outline-none focus:border-green-500" placeholder="司令官に質問や確認事項を入力..." disabled>
<button id="chat-send-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition duration-200 disabled:opacity-50" disabled>
送信
</button>
</div>
</div>
<!-- 司令部ログ表示エリア -->
<div class="bg-[#161b22] p-6 rounded-xl shadow-lg border border-[#30363d]">
<h2 class="text-xl font-semibold mb-3 text-white">司令部ログ (履歴)</h2>
<div id="log-display" class="scroll-container h-48 p-3 text-sm text-gray-300 whitespace-pre-wrap">
> ログがここに表示されます。まずは「名前・設定」から開始してください。
</div>
</div>
<!-- モーダルウィンドウ (名前・世界設定) -->
<div id="name-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-lg border border-[#30363d] max-h-[90vh] overflow-y-auto">
<h3 class="text-2xl font-bold mb-4 text-green-400">ゲーム設定</h3>
<div class="mb-4">
<label for="input-pilot-name" class="block text-sm font-medium mb-1 text-gray-300">パイロット名 (主人公)</label>
<input type="text" id="input-pilot-name" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white" placeholder="例: 主人公">
</div>
<div class="mb-4">
<label for="input-commander-name" class="block text-sm font-medium mb-1 text-gray-300">司令官名</label>
<input type="text" id="input-commander-name" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white" placeholder="例: 司令官">
</div>
<div class="mb-6">
<label for="input-world-setting" class="block text-sm font-medium mb-1 text-gray-300">世界観・設定 (AIロールプレイの参考資料)</label>
<p class="text-xs text-gray-500 mb-2">※自由に変更可能です。ミッションや会話の内容に反映されます。</p>
<textarea id="input-world-setting" rows="5" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white text-sm" placeholder="例: 大陸は二つの大国に分断され...">大陸全土を二分する軍事国家『帝国』と、自由都市同盟『連邦』の戦争が泥沼化している世界。
主人公はどの国家にも属さない中立の「傭兵ギルド」に所属する腕利きのパイロット。
報酬次第でどちらの陣営の依頼も受けるが、時には非合法な極秘任務も舞い込む。</textarea>
</div>
<div class="flex justify-end space-x-3">
<button id="save-name-btn" class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg">
保存して開始
</button>
<button id="close-name-modal" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg">
キャンセル
</button>
</div>
</div>
</div>
<!-- モーダルウィンドウ (格納庫/機体交換) -->
<div id="hangar-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-lg border border-[#30363d] max-h-[85vh] flex flex-col">
<h3 class="text-2xl font-bold mb-4 text-yellow-400 flex-shrink-0">格納庫 (機体交換)</h3>
<p class="mb-4 text-sm text-gray-400 flex-shrink-0">現在の資金: <span id="hangar-money-display" class="font-bold text-green-500">0</span> $</p>
<!-- スクロール可能な機体リストエリア -->
<div id="aircraft-list" class="space-y-4 overflow-y-auto pr-2 flex-grow scroll-container">
<!-- 機体カードがここに挿入される -->
</div>
<div class="flex justify-end mt-6 flex-shrink-0">
<button id="close-hangar-modal" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg">
閉じる
</button>
</div>
</div>
</div>
<!-- モーダルウィンドウ (メッセージ/アラート) -->
<div id="message-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-sm border border-[#30363d]">
<h3 id="message-title" class="text-2xl font-bold mb-4 text-white">通知</h3>
<p id="message-content" class="mb-6 text-gray-300">メッセージ内容</p>
<div class="flex justify-end">
<button id="close-message-modal" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg">
OK
</button>
</div>
</div>
</div>
</div>
<script type="module">
import { doc, setDoc, getDoc, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
document.addEventListener('firebaseAuthReady', setupGame);
const API_KEY = "";
const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=";
// --- 司令官の内部プロファイル設定 ---
const COMMANDER_PROFILE = {
gender: "女性",
appearance: "薄い紫の目とピンクのツインテールの髪をした10代後半の外見",
personality: "情熱的で、パイロットの成功を心から願っている。少しお茶目な一面もあり、パイロットを「相棒」として信頼している。",
tone: "自信に満ちており、かつ親しみやすい口調。感嘆符(!)や疑問符(?)を適切に使う。",
relationshipGrowth: "初期はビジネスライクだが、経験値(XP)が増えるにつれて、より個人的な会話や冗談を交わすようになる。1000XPを超えると、運命共同体としての深い絆を見せる。",
role: "オペレーター兼戦術指揮官"
};
// --- ゲーム状態と機体データ ---
let gameState = {
pilotName: '',
commanderName: '',
worldSetting: '',
currentAircraft: 'A-4 Skyhawk',
ownedAircrafts: ['A-4 Skyhawk'],
money: 2000, // ★ 初期資金は$2,000で固定
xp: 0,
missionLevel: 1,
missionCount: 0,
isFinalMissionAvailable: false,
currentMission: null,
chatHistory: [],
};
// 機体データを大幅に追加・調整
const AIRCRAFT_DATA = [
// 初期~低コスト帯
{ name: 'A-4 Skyhawk', display: 'A-4 スカイホーク', cost: 0, requiredXP: 0, performance: 'C' },
{ name: 'A-1J Skyraider', display: 'A-1J スカイレイダー', cost: 15000, requiredXP: 50, performance: 'C+' },
{ name: 'F-5E Tiger II', display: 'F-5E タイガーII', cost: 25000, requiredXP: 200, performance: 'C+' },
// 中盤~ベテラン帯
{ name: 'F-8E Crusader', display: 'F-8E クルセイダー', cost: 40000, requiredXP: 400, performance: 'B' },
{ name: 'A-7 Corsair II', display: 'A-7 コルセアII', cost: 45000, requiredXP: 450, performance: 'B' },
{ name: 'F-4 Phantom II', display: 'F-4 ファントム II', cost: 50000, requiredXP: 500, performance: 'B' },
{ name: 'A-6 Intruder', display: 'A-6 イントルーダー', cost: 60000, requiredXP: 600, performance: 'B+' },
{ name: 'A-10 Thunderbolt II', display: 'A-10 サンダーボルトII', cost: 70000, requiredXP: 700, performance: 'B+' },
{ name: 'AV-8 Harrier II', display: 'AV-8 ハリアーII', cost: 80000, requiredXP: 800, performance: 'B+' },
{ name: 'F-16 Fighting Falcon', display: 'F-16 ファイティング・ファルコン', cost: 90000, requiredXP: 900, performance: 'A-' },
// 高性能・エース帯
{ name: 'F-14 Tomcat', display: 'F-14 トムキャット', cost: 100000, requiredXP: 1000, performance: 'A' },
{ name: 'FA-18 Hornet', display: 'F/A-18 ホーネット', cost: 120000, requiredXP: 1200, performance: 'A' },
{ name: 'F-15 Eagle', display: 'F-15 イーグル', cost: 150000, requiredXP: 1500, performance: 'A' },
// 特殊・戦略兵器帯
{ name: 'F-117 Nighthawk', display: 'F-117 ナイトホーク', cost: 180000, requiredXP: 1800, performance: 'A+' },
{ name: 'B-52 Stratofortress', display: 'B-52 ストラトフォートレス', cost: 200000, requiredXP: 2000, performance: 'A+' },
{ name: 'B-1 Lancer', display: 'B-1 ランサー', cost: 250000, requiredXP: 2200, performance: 'S-' },
// 最強・次世代帯
{ name: 'F-35 Lightning II', display: 'F-35 ライトニングII', cost: 350000, requiredXP: 2500, performance: 'S' },
{ name: 'F-22 Raptor', display: 'F-22 ラプター', cost: 400000, requiredXP: 2800, performance: 'S' },
// 最終目標
{ name: 'Final Machine', display: '??? (最終目標)', cost: 500000, requiredXP: 3000, performance: 'S' }
];
// --- DOM要素 ---
const logDisplay = document.getElementById('log-display');
const startMissionBtn = document.getElementById('start-mission-btn');
const confirmMissionBtn = document.getElementById('confirm-mission-btn');
const nameConfigBtn = document.getElementById('name-config-btn');
const hangarBtn = document.getElementById('hangar-btn');
const finalMissionBtn = document.getElementById('final-mission-btn');
const loadingOverlay = document.getElementById('loading-overlay');
const briefingArea = document.getElementById('mission-briefing-area');
const briefingTitle = document.getElementById('briefing-title');
const briefingContent = document.getElementById('briefing-content');
const chatLog = document.getElementById('chat-log');
const chatInput = document.getElementById('chat-input');
const chatSendBtn = document.getElementById('chat-send-btn');
const nameModal = document.getElementById('name-modal');
const inputWorldSetting = document.getElementById('input-world-setting');
const hangarModal = document.getElementById('hangar-modal');
const messageModal = document.getElementById('message-modal');
const closeNameModal = document.getElementById('close-name-modal');
const saveNameBtn = document.getElementById('save-name-btn');
const closeHangarModal = document.getElementById('close-hangar-modal');
const closeMessageModal = document.getElementById('close-message-modal');
const aircraftList = document.getElementById('aircraft-list');
// --- Firestore関連関数 ---
function getDocRef() {
if (!window.firebase.db || !window.firebase.userId) {
console.error("Firebase/UserId not initialized.");
return null;
}
const userId = window.firebase.userId;
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
return doc(window.firebase.db, 'artifacts', appId, 'users', userId, 'merc_game', 'state');
}
async function saveGameState() {
const docRef = getDocRef();
if (docRef) {
try {
// currentMissionとchatHistoryはセーブデータから除外して保存
const stateToSave = {
pilotName: gameState.pilotName,
commanderName: gameState.commanderName,
worldSetting: gameState.worldSetting,
currentAircraft: gameState.currentAircraft,
ownedAircrafts: gameState.ownedAircrafts,
money: gameState.money,
xp: gameState.xp,
missionLevel: gameState.missionLevel,
missionCount: gameState.missionCount,
isFinalMissionAvailable: gameState.isFinalMissionAvailable,
};
await setDoc(docRef, stateToSave);
console.log("Game state saved successfully.");
} catch (error) {
console.error("Error saving game state:", error);
}
}
}
function setupFirestoreListener() {
const docRef = getDocRef();
if (docRef) {
onSnapshot(docRef, (doc) => {
if (doc.exists()) {
const loadedState = doc.data();
// ロードされたデータでgameStateを更新
Object.assign(gameState, loadedState);
// 互換性のため、ownedAircraftsがロードデータにない場合の初期化
if (!gameState.ownedAircrafts || gameState.ownedAircrafts.length === 0) {
gameState.ownedAircrafts = ['A-4 Skyhawk'];
if (gameState.currentAircraft !== 'A-4 Skyhawk' && gameState.currentAircraft) {
// 以前のバージョンからの移行の場合、現在の機体も所有機体に追加
gameState.ownedAircrafts.push(gameState.currentAircraft);
}
}
if (gameState.worldSetting) {
inputWorldSetting.value = gameState.worldSetting;
}
updateUI();
console.log("Game state loaded/updated from Firestore.");
} else {
console.log("No saved data found. Starting new game.");
gameState.worldSetting = inputWorldSetting.value;
saveGameState();
}
startMissionBtn.disabled = !(gameState.pilotName && gameState.commanderName);
hangarBtn.disabled = !(gameState.pilotName && gameState.commanderName);
finalMissionBtn.disabled = !gameState.isFinalMissionAvailable;
confirmMissionBtn.disabled = true;
}, (error) => {
console.error("Error listening to game state:", error);
});
}
}
// --- UI更新関数 ---
function updateUI() {
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft) || AIRCRAFT_DATA[0];
document.getElementById('user-id-display').textContent = `ユーザーID: ${window.firebase.userId || '未認証'}`;
document.getElementById('pilot-display').textContent = gameState.pilotName ? `パイロット: ${gameState.pilotName}` : '未設定';
document.getElementById('commander-display').textContent = gameState.commanderName ? `司令官: ${gameState.commanderName}` : '未設定';
document.getElementById('pilot-name-display').textContent = gameState.pilotName || '---';
document.getElementById('commander-name-display').textContent = gameState.commanderName || '---';
document.getElementById('aircraft-display').textContent = currentAircraftData.display;
document.getElementById('money-display').textContent = gameState.money.toLocaleString();
document.getElementById('xp-display').textContent = gameState.xp.toLocaleString();
startMissionBtn.disabled = !(gameState.pilotName && gameState.commanderName);
hangarBtn.disabled = !(gameState.pilotName && gameState.commanderName);
finalMissionBtn.disabled = !gameState.isFinalMissionAvailable;
if (gameState.xp >= 2500 && gameState.money >= 300000 && !gameState.isFinalMissionAvailable) {
gameState.isFinalMissionAvailable = true;
finalMissionBtn.disabled = false;
appendLog(`[${gameState.commanderName}]:ついに時が来たぞ、${gameState.pilotName}。最終目標「プロジェクト・ロンギヌス」の実行許可が下りた。`, 'text-purple-400');
saveGameState();
}
if (!gameState.currentMission) {
briefingArea.classList.add('hidden');
confirmMissionBtn.disabled = true;
chatInput.disabled = true;
chatSendBtn.disabled = true;
}
}
function appendLog(message, colorClass = 'text-gray-300') {
const timestamp = new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
const logEntry = document.createElement('p');
logEntry.className = `mt-1 ${colorClass}`;
logEntry.textContent = `[${timestamp}] ${message}`;
logDisplay.appendChild(logEntry);
logDisplay.scrollTop = logDisplay.scrollHeight;
}
function showAlert(title, content) {
document.getElementById('message-title').textContent = title;
document.getElementById('message-content').textContent = content;
messageModal.classList.remove('hidden');
messageModal.classList.add('flex');
}
// --- チャットUI関連関数 ---
function appendChatMessage(role, text) {
const messageElement = document.createElement('div');
const roleText = role === 'commander' ? gameState.commanderName : gameState.pilotName;
const messageClass = role === 'commander' ? 'message-commander' : 'message-pilot';
messageElement.className = `max-w-[80%] p-3 text-sm text-white shadow-md ${messageClass}`;
messageElement.innerHTML = `<span class="font-bold text-xs ${role === 'commander' ? 'text-green-300' : 'text-blue-300'}">${roleText}:</span> ${text}`;
chatLog.appendChild(messageElement);
chatLog.scrollTop = chatLog.scrollHeight;
}
function renderChatHistory() {
chatLog.innerHTML = '';
gameState.chatHistory.forEach(msg => {
appendChatMessage(msg.role, msg.text);
});
}
// --- LLM API関連関数 ---
async function callGeminiApi(payload, maxRetries = 5, isJson = false, schema = null) {
const url = API_URL + API_KEY;
let attempt = 0;
while (attempt < maxRetries) {
try {
if (isJson && schema) {
payload.generationConfig = {
responseMimeType: "application/json",
responseSchema: schema
};
} else if (payload.generationConfig) {
delete payload.generationConfig;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 429) {
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
attempt++;
continue;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const candidate = result.candidates?.[0];
if (candidate && candidate.content?.parts?.[0]?.text) {
const text = candidate.content.parts[0].text;
if (isJson) {
try {
return JSON.parse(text);
} catch (e) {
console.error("Failed to parse JSON from LLM response:", text);
// JSONパース失敗時は、エラーを投げてexecuteMissionのFallbackに任せる
throw new Error("Invalid JSON response from LLM.");
}
}
return text;
} else {
throw new Error("No valid content found in LLM response.");
}
} catch (error) {
// APIコール自体の失敗、またはJSONパース失敗のエラーを再スロー
console.error(`Attempt ${attempt + 1} failed:`, error);
attempt++;
if (attempt >= maxRetries) {
throw new Error("API call failed after multiple retries.");
}
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// --- システムプロンプト生成ヘルパー ---
function createBaseSystemPrompt() {
return `
あなたは傭兵部隊の司令官「${gameState.commanderName}」です。
【司令官プロファイル】
- 性別: ${COMMANDER_PROFILE.gender}
- 外見/イメージ: ${COMMANDER_PROFILE.appearance}
- 性格: ${COMMANDER_PROFILE.personality}
- 口調: ${COMMANDER_PROFILE.tone}
- パイロットとの関係性: ${COMMANDER_PROFILE.relationshipGrowth}
- 現在のパイロットの経験値(XP): ${gameState.xp} (この値が高いほど信頼関係は深いです)
【世界設定】
${gameState.worldSetting}
あなたはパイロット「${gameState.pilotName}」のパートナーとして振る舞ってください。
`;
}
// --- ミッション生成・ブリーフィング表示 ---
async function generateMission() {
loadingOverlay.classList.remove('hidden');
startMissionBtn.disabled = true;
hangarBtn.disabled = true;
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
${createBaseSystemPrompt()}
現在の難易度レベルに基づいて、簡潔なミッションを提示してください。
ミッションの難易度はレベル${gameState.missionLevel}です。
現在の機体は「${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})」です。
★★重要★★
ミッション報酬は難易度レベル1でも**最低5000ドル**に設定してください。
結果を以下のJSONスキーマに従って日本語で、感情を込めて出力してください。
`;
const userQuery = gameState.isFinalMissionAvailable
? `最終目標「プロジェクト・ロンギヌス」に向けた、極秘で危険度の高い重要なミッションを提案せよ。`
: `現在の機体「${currentAircraftData.display}」で実行可能な、レベル${gameState.missionLevel}のランダムなミッション(偵察、迎撃、護衛、対地攻撃など)を提案せよ。`;
const missionSchema = {
type: "OBJECT",
properties: {
missionTitle: { "type": "STRING", "description": "ミッションの簡潔なタイトル" },
briefing: { "type": "STRING", "description": "司令官からの詳細なブリーフィング(世界設定を反映させること)" },
// ★ 最低報酬額が$5,000になるように、JSONスキーマの指示も明確に修正
baseRewardMoney: { "type": "INTEGER", "description": `成功時の基本報酬額。ミッションレベル1の場合、最低金額は5000ドルとし、レベルに応じて最大50,000ドルまで設定すること。` },
baseRewardXP: { "type": "INTEGER", "description": "成功時の基本獲得経験値(難易度レベルに応じて、50~500程度)" },
difficultyRating: { "type": "STRING", "description": "ミッションの難易度(例: E, D, C, B, A, S)" }
},
required: ["missionTitle", "briefing", "baseRewardMoney", "baseRewardXP", "difficultyRating"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
const missionData = await callGeminiApi(payload, 5, true, missionSchema);
gameState.currentMission = missionData;
gameState.chatHistory = [];
briefingTitle.textContent = `ミッション名: ${missionData.missionTitle} (難易度: ${missionData.difficultyRating})`;
briefingContent.innerHTML = `
<p class="text-sm text-gray-400">レベル: ${gameState.missionLevel} / 機体: ${currentAircraftData.display}</p>
<p class="text-sm text-gray-400 mb-2">基本報酬: ${missionData.baseRewardMoney.toLocaleString()}$ / 基本XP: ${missionData.baseRewardXP} XP</p>
<p class="mt-2 text-lg whitespace-pre-wrap"><span class="font-bold text-green-300">${gameState.commanderName}:</span>${missionData.briefing}</p>
`;
briefingArea.classList.remove('hidden');
const initialMessage = `${gameState.pilotName}、ブリーフィングは確認した?不明点があれば聞いて。準備ができたら出撃よ。`;
gameState.chatHistory.push({ role: "commander", text: initialMessage });
renderChatHistory();
confirmMissionBtn.disabled = false;
chatInput.disabled = false;
chatSendBtn.disabled = false;
startMissionBtn.disabled = true;
chatInput.focus();
appendLog(`[システム]:新しいミッション「${missionData.missionTitle}」のブリーフィングを受信しました。`, 'text-yellow-400');
} catch (error) {
console.error("Mission generation failed:", error);
showAlert("エラー", "ブリーフィング生成に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
document.querySelector('#loading-overlay p').textContent = '通信中... ブリーフィングを受信しています';
hangarBtn.disabled = false;
}
}
// --- チャット処理 ---
async function handleChat() {
const userText = chatInput.value.trim();
if (!userText || !gameState.currentMission) return;
gameState.chatHistory.push({ role: "pilot", text: userText });
appendChatMessage("pilot", userText);
chatInput.value = '';
chatInput.disabled = true;
chatSendBtn.disabled = true;
const chatSystemPrompt = `
${createBaseSystemPrompt()}
現在のミッションは「${gameState.currentMission.missionTitle}」です。
パイロットからの質問や意見に対し、プロファイル設定(${COMMANDER_PROFILE.personality})に基づいて応答してください。
現在の経験値は${gameState.xp}です。経験値が高い場合、より親密な信頼関係を示してください。
以下の過去の会話履歴に基づき、簡潔に日本語で応答してください。
`;
const contents = gameState.chatHistory.map(msg => ({
role: msg.role === 'pilot' ? 'user' : 'model',
parts: [{ text: msg.text }]
}));
const payload = {
contents: contents,
systemInstruction: { parts: [{ text: chatSystemPrompt }] },
};
try {
const commanderResponse = await callGeminiApi(payload, 5, false);
gameState.chatHistory.push({ role: "commander", text: commanderResponse });
appendChatMessage("commander", commanderResponse);
} catch (error) {
console.error("Chat generation failed:", error);
const fallbackMessage = "通信ノイズが入ったわ。もう一度頼める?";
gameState.chatHistory.push({ role: "commander", text: fallbackMessage });
appendChatMessage("commander", fallbackMessage);
} finally {
chatInput.disabled = false;
chatSendBtn.disabled = false;
chatInput.focus();
}
}
// --- ミッション実行 ---
async function executeMission() {
if (!gameState.currentMission) {
showAlert("エラー", "実行するミッションブリーフィングがありません。");
return;
}
loadingOverlay.classList.remove('hidden');
document.querySelector('#loading-overlay p').textContent = '作戦実行中... 結果を分析しています';
confirmMissionBtn.disabled = true;
startMissionBtn.disabled = true;
chatInput.disabled = true;
chatSendBtn.disabled = true;
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const missionData = gameState.currentMission;
const chatSummary = gameState.chatHistory.map(msg => `${msg.role === 'pilot' ? gameState.pilotName : gameState.commanderName}: ${msg.text}`).join('\n');
const systemPrompt = `
あなたは空戦の専門家であり、この作戦の評価官です。
【世界設定】
${gameState.worldSetting}
パイロット「${gameState.pilotName}」のミッション結果を、機体の性能、ミッション難易度、そして**司令官との事前のチャット内容**を総合的に考慮して判定してください。
チャット履歴におけるパイロットの態度や準備状況は、成功率に影響します。
【ミッションデータ】
基本報酬: ${missionData.baseRewardMoney.toLocaleString()}$
基本XP: ${missionData.baseRewardXP} XP
★★重要★★
ミッションに成功した場合、獲得資金(moneyEarned)は基本報酬(${missionData.baseRewardMoney.toLocaleString()}$)を**絶対**に下回らないように設定してください。
結果を物語風に記述し、報酬を計算して、以下のJSONスキーマに従って出力してください。
`;
const userQuery = `
ミッション: ${missionData.missionTitle} (難易度: ${missionData.difficultyRating} / レベル: ${gameState.missionLevel})
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
事前の司令官とのチャット履歴:
---
${chatSummary}
---
この条件で、作戦の結果(成否、物語、獲得資金、獲得XP)を判定してください。
`;
const resultSchema = {
type: "OBJECT",
properties: {
success: { "type": "BOOLEAN", "description": "ミッションの成否" },
narration: { "type": "STRING", "description": "戦闘結果の物語風な描写" },
moneyEarned: { "type": "INTEGER", "description": "獲得した資金(失敗時は0またはマイナス)" },
xpEarned: { "type": "INTEGER", "description": "獲得した経験値(失敗時は少ない)" }
},
required: ["success", "narration", "moneyEarned", "xpEarned"]
};
let resultData;
try {
// LLMに厳密なJSONを要求する
resultData = await callGeminiApi(payload, 5, true, resultSchema);
} catch (apiError) {
// ★★★ LLM判定失敗時の緊急プロトコル (Deterministic Fallback) ★★★
console.warn("Attempting deterministic fallback due to API/JSON failure:", apiError);
const successRate = 0.7; // 70%の確率で成功と判定する
const success = Math.random() < successRate;
const baseRewardMoney = missionData.baseRewardMoney;
const baseRewardXP = missionData.baseRewardXP;
if (success) {
resultData = {
success: true,
narration: `${gameState.commanderName}:通信障害の最中、君は完璧に任務を遂行したわ!流石ね、相棒!システムは正常な報酬を保証する!`,
moneyEarned: baseRewardMoney, // Guaranteed minimum
xpEarned: baseRewardXP,
};
} else {
resultData = {
success: false,
narration: `${gameState.commanderName}:システム障害で詳細不明だが、作戦は失敗と判定されたわ...。君の帰投は確認したが、損害が出たようね。`,
moneyEarned: 0 - Math.floor(baseRewardMoney * 0.1), // 小さなペナルティ
xpEarned: Math.floor(baseRewardXP * 0.1), // 少ないXP
};
}
appendLog(`[システム]:AI判定に失敗したため、緊急プロトコル(成功率70%)を適用しました。`, 'text-orange-500');
}
// ★★★ 緊急プロトコル終了 ★★★
// AI判定または緊急プロトコルで得られた結果を処理
// 報酬の強制保証 (AI判定が成功した場合のみ)
if (resultData.success) {
const guaranteedMoney = Math.max(resultData.moneyEarned, missionData.baseRewardMoney);
if (guaranteedMoney > resultData.moneyEarned) {
appendLog(`[警告]:AI出力の獲得資金(${resultData.moneyEarned.toLocaleString()}$)が基本報酬(${missionData.baseRewardMoney.toLocaleString()}$)を下回ったため、システムが資金を保証額(${guaranteedMoney.toLocaleString()}$)に修正しました。`, 'text-red-500');
}
resultData.moneyEarned = guaranteedMoney;
}
gameState.money += resultData.moneyEarned;
gameState.xp += resultData.xpEarned;
gameState.missionCount += 1;
if (gameState.missionCount > 0 && gameState.missionCount % 5 === 0) {
gameState.missionLevel += 1;
appendLog(`難易度レベルが上昇しました!現在のレベル: ${gameState.missionLevel}`, 'text-yellow-500 font-bold');
}
// --- 撃墜(ミッション失敗)時の機体ペナルティロジック ---
if (!resultData.success) {
const repairCost = Math.floor(gameState.money * 0.1) + 1000; // 資金の10% + 1000ドルを修理費とする
if (gameState.currentAircraft !== 'A-4 Skyhawk') {
// A-4以外の機体だった場合、機体喪失
const lostAircraftDisplay = currentAircraftData.display;
// 機体喪失処理:所有機体リストから削除
gameState.ownedAircrafts = gameState.ownedAircrafts.filter(name => name !== gameState.currentAircraft);
gameState.money = Math.max(0, gameState.money - repairCost); // 資金がマイナスにならないように
gameState.currentAircraft = 'A-4 Skyhawk'; // 初期機体に戻す
appendLog(`[警報]:現在の機体「${lostAircraftDisplay}」は戦闘不能!「A-4 スカイホーク」に強制換装し、修理費として ${repairCost.toLocaleString()}$ が差し引かれました。`, 'text-red-600 font-extrabold');
showAlert("機体喪失", `「${lostAircraftDisplay}」は戦闘不能と判定され、初期機体「A-4 スカイホーク」に戻されました。修理費用として ${repairCost.toLocaleString()}$ が差し引かれました。`);
} else {
// A-4だった場合、資金のみのペナルティ
const minRepairCost = 500;
gameState.money = Math.max(0, gameState.money - minRepairCost); // 最小限の修理費
appendLog(`[警告]:A-4 スカイホークが損傷。修理費として ${minRepairCost}$ が差し引かれました。`, 'text-red-500');
}
}
// ---------------------------------------------------
appendLog(`\n--- ミッション結果: ${missionData.missionTitle} ---`, 'text-white font-bold');
appendLog(`[評価官]:${resultData.narration}`, resultData.success ? 'text-blue-300' : 'text-red-400');
appendLog(`[結果]:${resultData.success ? '成功' : '失敗'}`, resultData.success ? 'text-green-500' : 'text-red-500');
appendLog(`[獲得]:資金 ${resultData.moneyEarned.toLocaleString()}$ / 経験値 ${resultData.xpEarned} XP`, 'text-cyan-400');
appendLog(`[${gameState.commanderName}]:作戦終了。帰投を確認。`, 'text-pink-400');
gameState.currentMission = null;
gameState.chatHistory = [];
briefingArea.classList.add('hidden');
startMissionBtn.disabled = false;
await saveGameState();
updateUI();
loadingOverlay.classList.add('hidden');
document.querySelector('#loading-overlay p').textContent = '通信中... ブリーフィングを受信しています';
hangarBtn.disabled = false;
}
// --- 最終ミッション実行 ---
async function executeFinalMission() {
if (!gameState.isFinalMissionAvailable) {
showAlert("通知", "最終目標の準備が整っていません。さらに多くのミッションをこなし、経験値を積んでください。");
return;
}
loadingOverlay.classList.remove('hidden');
document.querySelector('#loading-overlay p').textContent = '最終目標作戦実行中...';
startMissionBtn.disabled = true;
hangarBtn.disabled = true;
finalMissionBtn.disabled = true;
confirmMissionBtn.disabled = true;
briefingArea.classList.add('hidden');
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
あなたは空戦の専門家であり、最終目標「プロジェクト・ロンギヌス」作戦の評価官です。
【世界設定】
${gameState.worldSetting}
パイロット「${gameState.pilotName}」の作戦結果を、機体「${currentAircraftData.display}」の性能と、これまでの彼の功績を考慮して判定してください。
結果は、大勝利、勝利、または作戦失敗のいずれかです。
結果を物語風に記述し、ゲームの最終的な結末として提示してください。
結果はJSONスキーマに従って出力してください。
`;
const userQuery = `
最終目標: プロジェクト・ロンギヌス
パイロット: ${gameState.pilotName}
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
これまでの総経験値: ${gameState.xp.toLocaleString()} XP
この条件で、作戦の結果(成否、物語)を判定してください。最終ミッションのため、報酬の計算は不要です。
`;
const finalResultSchema = {
type: "OBJECT",
properties: {
success: { "type": "BOOLEAN", "description": "最終目標の成否" },
ending: { "type": "STRING", "description": "ゲームの最終的な結末となる物語" }
},
required: ["success", "ending"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
const resultData = await callGeminiApi(payload, 5, true, finalResultSchema);
appendLog(`\n--- 最終目標「プロジェクト・ロンギヌス」結果 ---`, 'text-red-500 font-extrabold text-lg');
appendLog(`[結末]:${resultData.ending}`, resultData.success ? 'text-yellow-200' : 'text-red-400');
appendLog(`ゲーム終了。${gameState.pilotName}の傭兵としての伝説はここで幕を閉じる...`, 'text-white font-bold');
startMissionBtn.disabled = true;
finalMissionBtn.disabled = true;
hangarBtn.disabled = true;
gameState.isFinalMissionAvailable = false;
await saveGameState();
} catch (error) {
console.error("Final mission execution failed:", error);
showAlert("エラー", "最終目標の判定に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
}
}
// --- 格納庫 (機体交換) ロジック ---
function openHangar() {
document.getElementById('hangar-money-display').textContent = gameState.money.toLocaleString();
aircraftList.innerHTML = '';
AIRCRAFT_DATA.forEach(aircraft => {
const isCurrent = aircraft.name === gameState.currentAircraft;
// 機体を持っているかどうかをチェック
const isOwned = gameState.ownedAircrafts.includes(aircraft.name);
const canAfford = gameState.money >= aircraft.cost;
const meetsXP = gameState.xp >= aircraft.requiredXP;
const canPurchase = !isOwned && canAfford && meetsXP;
let buttonHtml = '';
let cardClass = '';
if (isCurrent) {
buttonHtml = `<button class="w-full bg-gray-500 text-white font-bold py-2 px-4 rounded-lg cursor-default" disabled>現在搭乗中</button>`;
cardClass = 'border-yellow-500 bg-yellow-900/20';
} else if (isOwned) {
// ★ 乗り換えボタン: switch-btnクラス
buttonHtml = `<button class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg switch-btn" data-aircraft-name="${aircraft.name}">乗り換え</button>`;
cardClass = 'border-[#444c56] bg-[#30363d] opacity-90';
} else if (canPurchase) {
// ★ 購入ボタン: purchase-btnクラス
buttonHtml = `<button class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg purchase-btn" data-aircraft-name="${aircraft.name}">購入 (${aircraft.cost.toLocaleString()}$)</button>`;
cardClass = 'border-[#444c56] bg-[#30363d] hover:bg-[#3e444b]';
} else {
buttonHtml = `<button class="w-full bg-red-800 text-white font-bold py-2 px-4 rounded-lg cursor-default" disabled>
${!canAfford ? '資金不足' : !meetsXP ? 'XP不足' : '購入不可'}
</button>`;
cardClass = 'border-[#444c56] bg-[#30363d] opacity-50';
}
const costDisplay = aircraft.cost > 0 ? ` / ${aircraft.cost.toLocaleString()}$` : '';
const cardHtml = `
<div class="p-4 rounded-lg border ${cardClass}">
<h4 class="text-xl font-bold ${isCurrent ? 'text-yellow-300' : 'text-white'}">${aircraft.display}</h4>
<p class="text-sm text-gray-400 mt-1">性能ランク: <span class="font-bold">${aircraft.performance}</span></p>
<p class="text-sm text-gray-400">必要XP: ${aircraft.requiredXP.toLocaleString()} XP${costDisplay}</p>
<div class="mt-3">
${buttonHtml}
</div>
</div>
`;
aircraftList.insertAdjacentHTML('beforeend', cardHtml);
});
// ★修正: イベントリスナーで e.target ではなく e.currentTarget を使用し、要素の正確な参照を保証
// 購入ボタンのイベントリスナー
aircraftList.querySelectorAll('.purchase-btn').forEach(button => {
button.addEventListener('click', (e) => {
// e.currentTargetはリスナーが設定されたボタン要素自体を指すため、属性取得が確実になる
const newAircraftName = e.currentTarget.getAttribute('data-aircraft-name');
handleAircraftAction(newAircraftName, 'purchase');
});
});
// 乗り換えボタンのイベントリスナー
aircraftList.querySelectorAll('.switch-btn').forEach(button => {
button.addEventListener('click', (e) => {
// e.currentTargetはリスナーが設定されたボタン要素自体を指すため、属性取得が確実になる
const newAircraftName = e.currentTarget.getAttribute('data-aircraft-name');
handleAircraftAction(newAircraftName, 'switch');
});
});
hangarModal.classList.remove('hidden');
hangarModal.classList.add('flex');
}
async function handleAircraftAction(aircraftName, action) {
const aircraftData = AIRCRAFT_DATA.find(a => a.name === aircraftName);
if (!aircraftData) return;
if (action === 'purchase') {
// 購入処理: 資金チェックと消費
if (gameState.money >= aircraftData.cost) {
gameState.money -= aircraftData.cost;
gameState.ownedAircrafts.push(aircraftName);
gameState.currentAircraft = aircraftName; // 購入したら自動で乗り換え
appendLog(`[システム]:新しい機体「${aircraftData.display}」を購入しました。資金を${aircraftData.cost.toLocaleString()}$消費。`, 'text-yellow-500');
await saveGameState();
updateUI();
openHangar();
showAlert("購入完了", `「${aircraftData.display}」を購入し、搭乗しました。`);
} else {
// 資金不足アラート
showAlert("資金不足", `${aircraftData.display}を購入するための資金が不足しています。`);
}
} else if (action === 'switch') {
// 乗り換え処理: 資金チェック不要、所有チェックのみ
if (!gameState.ownedAircrafts.includes(aircraftName)) {
// 二重チェック。本来はボタンが表示されないはずだが、安全のため。
console.error(`Attempted to switch to non-owned aircraft: ${aircraftName}`);
showAlert("エラー", "その機体は所有機体リストにありません。不正な操作です。");
return;
}
if (gameState.currentAircraft !== aircraftName) {
gameState.currentAircraft = aircraftName;
appendLog(`[システム]:機体「${aircraftData.display}」に乗り換えました。`, 'text-blue-500');
await saveGameState();
updateUI();
openHangar();
showAlert("乗り換え完了", `「${aircraftData.display}」に乗り換えました。`);
}
}
}
// --- イベントリスナー設定と初期化 ---
function setupGame() {
setupFirestoreListener();
nameConfigBtn.addEventListener('click', () => {
document.getElementById('input-pilot-name').value = gameState.pilotName;
document.getElementById('input-commander-name').value = gameState.commanderName;
if (gameState.worldSetting) {
inputWorldSetting.value = gameState.worldSetting;
}
nameModal.classList.remove('hidden');
nameModal.classList.add('flex');
});
closeNameModal.addEventListener('click', () => {
nameModal.classList.remove('flex');
nameModal.classList.add('hidden');
});
saveNameBtn.addEventListener('click', async () => {
const pilotName = document.getElementById('input-pilot-name').value.trim();
const commanderName = document.getElementById('input-commander-name').value.trim();
const worldSetting = inputWorldSetting.value.trim();
if (pilotName && commanderName) {
const isNewGame = !(gameState.pilotName && gameState.commanderName);
gameState.pilotName = pilotName;
gameState.commanderName = commanderName;
gameState.worldSetting = worldSetting;
await saveGameState();
updateUI();
nameModal.classList.remove('flex');
nameModal.classList.add('hidden');
if (isNewGame) {
appendLog(`[${gameState.commanderName}]:ようこそ、${gameState.pilotName}。これが君の愛機、A-4 スカイホークだ。ここから伝説を築き上げろ。`, 'text-green-500');
showAlert("開始", `${gameState.pilotName} パイロットと ${gameState.commanderName} 司令官の名前が設定されました。ブリーフィングを表示してミッションを開始しましょう!`);
} else {
showAlert("更新完了", "設定が更新されました。");
}
} else {
showAlert("入力エラー", "パイロット名と司令官名の両方を入力してください。");
}
});
startMissionBtn.addEventListener('click', generateMission);
confirmMissionBtn.addEventListener('click', executeMission);
chatSendBtn.addEventListener('click', handleChat);
chatInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleChat();
e.preventDefault();
}
});
hangarBtn.addEventListener('click', openHangar);
closeHangarModal.addEventListener('click', () => {
hangarModal.classList.remove('flex');
hangarModal.classList.add('hidden');
});
finalMissionBtn.addEventListener('click', executeFinalMission);
closeMessageModal.addEventListener('click', () => {
messageModal.classList.remove('flex');
messageModal.classList.add('hidden');
});
}
</script>
</body>
</html>
V1.0 一応、これで仮完成となります。
まだ、拘りはあるんですが、その辺はバランス修正なるので
これで次のテストへ移ります。
わかる人はわかると思いますが、世界設定はご自身で『お好きなように書き換えできます』、なので自分で楽しむのは自由ですが、公開による各方面への著作権侵害は行わないようにお願いします。
こちらでは関知できませんのでご注意下さい。
AIによるストーリージェネレートは『恐らくはあらゆるこれまでやりたかった戦場シチュエーションを再現可能』かもしれません。
転売や自分が作ったと名乗るようなのはご遠慮ください。
また、使用した結果への損害等は補償できませんので自己責任でお楽しみください。
canvasモードでテストしています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大空の狼達 - Wolves of the Sky</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Interフォントをロード -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<!-- Firebase SDKのインポート (コアサービスとAuthのみ) -->
<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, setLogLevel } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
setLogLevel('Debug');
window.firebase = {
db: null,
auth: null,
userId: null,
isAuthReady: false
};
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = JSON.parse(typeof __firebase_config !== 'undefined' ? __firebase_config : '{}');
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
if (Object.keys(firebaseConfig).length > 0) {
const app = initializeApp(firebaseConfig);
window.firebase.db = getFirestore(app);
window.firebase.auth = getAuth(app);
onAuthStateChanged(window.firebase.auth, async (user) => {
if (user) {
window.firebase.userId = user.uid;
} else {
if (initialAuthToken) {
try {
await signInWithCustomToken(window.firebase.auth, initialAuthToken);
} catch (error) {
console.error("Custom token sign-in failed:", error);
await signInAnonymously(window.firebase.auth);
}
} else {
await signInAnonymously(window.firebase.auth);
}
window.firebase.userId = window.firebase.auth.currentUser?.uid || crypto.randomUUID();
}
window.firebase.isAuthReady = true;
document.dispatchEvent(new CustomEvent('firebaseAuthReady'));
});
} else {
console.error("Firebase configuration is missing.");
}
</script>
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #0d1117;
color: #c9d1d9;
}
.scroll-container {
overflow-y: auto;
}
.scroll-container::-webkit-scrollbar {
width: 8px;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: #30363d;
border-radius: 4px;
}
.scroll-container::-webkit-scrollbar-track {
background: #161b22;
}
.chat-log {
height: 350px;
background-color: #161b22;
border-top: 2px solid #30363d;
}
.message-commander {
align-self: flex-start;
background-color: #213c32;
border-radius: 12px 12px 12px 0;
}
.message-pilot {
align-self: flex-end;
background-color: #293855;
border-radius: 12px 12px 0 12px;
}
</style>
</head>
<body class="p-4 sm:p-6 lg:p-8">
<div id="loading-overlay" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 transition-opacity duration-300 hidden">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto"></div>
<p class="mt-4 text-lg font-semibold text-green-400">通信中... ブリーフィングを受信しています</p>
</div>
</div>
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold mb-6 text-green-400 tracking-wider">大空の狼達 - Wolves of the Sky</h1>
<p id="user-id-display" class="text-sm text-gray-500 mb-4">ユーザーID: Loading...</p>
<!-- ステータス表示カード -->
<div class="bg-[#161b22] p-6 rounded-xl shadow-lg mb-6 border border-[#30363d] grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-400">パイロット名 (<span id="pilot-display">未設定</span>)</p>
<h2 id="pilot-name-display" class="text-2xl font-semibold text-white">---</h2>
<p class="text-sm text-gray-400 mt-2">司令官名 (<span id="commander-display">未設定</span>)</p>
<h2 id="commander-name-display" class="text-xl font-semibold text-gray-300">---</h2>
</div>
<div>
<p class="text-sm text-gray-400">現在の機体</p>
<h2 id="aircraft-display" class="text-3xl font-bold text-yellow-300">A-4 Skyhawk</h2>
<div class="mt-2 text-lg">
<p>資金: <span id="money-display" class="font-bold text-green-500">0</span> $</p>
<p>経験値: <span id="xp-display" class="font-bold text-blue-400">0</span> XP</p>
</div>
</div>
</div>
<!-- アクションと設定セクション -->
<div class="mb-6 grid grid-cols-1 sm:grid-cols-4 gap-4">
<button id="start-mission-btn" class="col-span-2 sm:col-span-2 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
<i class="fas fa-satellite-dish"></i> ブリーフィングを表示
</button>
<button id="confirm-mission-btn" class="col-span-2 sm:col-span-2 bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
<i class="fas fa-fighter-jet"></i> ミッション開始
</button>
<button id="name-config-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200">
名前・設定
</button>
<button id="hangar-btn" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
格納庫 (機体交換)
</button>
<button id="final-mission-btn" class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50 col-span-2" disabled>
最終目標へ
</button>
</div>
<!-- ブリーフィング&チャットエリア -->
<div id="mission-briefing-area" class="bg-[#161b22] p-6 rounded-xl shadow-lg border border-[#30363d] mb-6 hidden">
<h2 id="briefing-title" class="text-2xl font-bold mb-3 text-yellow-400">ミッション・タイトル</h2>
<div id="briefing-content" class="text-gray-300 whitespace-pre-wrap mb-4 border-b border-[#30363d] pb-4"></div>
<h3 class="text-xl font-semibold mb-3 text-white">司令官とのチャット</h3>
<div id="chat-log" class="chat-log scroll-container p-3 flex flex-col space-y-3"></div>
<div id="chat-container" class="mt-4 flex space-x-2">
<input type="text" id="chat-input" class="flex-grow p-3 rounded-lg bg-[#30363d] border border-[#444c56] text-white focus:outline-none focus:border-green-500" placeholder="司令官に質問や確認事項を入力..." disabled>
<button id="chat-send-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition duration-200 disabled:opacity-50" disabled>
送信
</button>
</div>
</div>
<!-- 司令部ログ表示エリア -->
<div class="bg-[#161b22] p-6 rounded-xl shadow-lg border border-[#30363d]">
<h2 class="text-xl font-semibold mb-3 text-white">司令部ログ (履歴)</h2>
<div id="log-display" class="scroll-container h-48 p-3 text-sm text-gray-300 whitespace-pre-wrap">
> ログがここに表示されます。まずは「名前・設定」から開始してください。
</div>
</div>
<!-- モーダルウィンドウ (名前・世界設定) -->
<div id="name-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-lg border border-[#30363d] max-h-[90vh] overflow-y-auto">
<h3 class="text-2xl font-bold mb-4 text-green-400">ゲーム設定</h3>
<div class="mb-4">
<label for="input-pilot-name" class="block text-sm font-medium mb-1 text-gray-300">パイロット名 (主人公)</label>
<input type="text" id="input-pilot-name" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white" placeholder="例: 主人公">
</div>
<div class="mb-4">
<label for="input-commander-name" class="block text-sm font-medium mb-1 text-gray-300">司令官名</label>
<input type="text" id="input-commander-name" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white" placeholder="例: 司令官">
</div>
<div class="mb-6">
<label for="input-world-setting" class="block text-sm font-medium mb-1 text-gray-300">世界観・設定 (AIロールプレイの参考資料)</label>
<p class="text-xs text-gray-500 mb-2">※自由に変更可能です。ミッションや会話の内容に反映されます。</p>
<textarea id="input-world-setting" rows="5" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white text-sm" placeholder="例: 大陸は二つの大国に分断され...">大陸全土を二分する軍事国家『帝国』と、自由都市同盟『連邦』の戦争が泥沼化している世界。
主人公はどの国家にも属さない中立の「傭兵ギルド」に所属する腕利きのパイロット。
報酬次第でどちらの陣営の依頼も受けるが、時には非合法な極秘任務も舞い込む。</textarea>
</div>
<div class="flex justify-end space-x-3">
<button id="save-name-btn" class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg">
保存して開始
</button>
<button id="close-name-modal" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg">
キャンセル
</button>
</div>
</div>
</div>
<!-- モーダルウィンドウ (格納庫/機体交換) -->
<div id="hangar-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-lg border border-[#30363d] max-h-[85vh] flex flex-col">
<h3 class="text-2xl font-bold mb-4 text-yellow-400 flex-shrink-0">格納庫 (機体交換)</h3>
<p class="mb-4 text-sm text-gray-400 flex-shrink-0">現在の資金: <span id="hangar-money-display" class="font-bold text-green-500">0</span> $</p>
<!-- スクロール可能な機体リストエリア -->
<div id="aircraft-list" class="space-y-4 overflow-y-auto pr-2 flex-grow scroll-container">
<!-- 機体カードがここに挿入される -->
</div>
<div class="flex justify-end mt-6 flex-shrink-0">
<button id="close-hangar-modal" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg">
閉じる
</button>
</div>
</div>
</div>
<!-- モーダルウィンドウ (メッセージ/アラート) -->
<div id="message-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-sm border border-[#30363d]">
<h3 id="message-title" class="text-2xl font-bold mb-4 text-white">通知</h3>
<p id="message-content" class="mb-6 text-gray-300">メッセージ内容</p>
<div class="flex justify-end">
<button id="close-message-modal" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg">
OK
</button>
</div>
</div>
</div>
</div>
<script type="module">
import { doc, setDoc, getDoc, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
document.addEventListener('firebaseAuthReady', setupGame);
const API_KEY = "";
const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=";
// --- 司令官の内部プロファイル設定 ---
const COMMANDER_PROFILE = {
gender: "女性",
appearance: "薄い紫の目とピンクのツインテールの髪をした10代後半の外見",
personality: "情熱的で、パイロットの成功を心から願っている。少しお茶目な一面もあり、パイロットを「相棒」として信頼している。",
tone: "自信に満ちており、かつ親しみやすい口調。感嘆符(!)や疑問符(?)を適切に使う。",
relationshipGrowth: "初期はビジネスライクだが、経験値(XP)が増えるにつれて、より個人的な会話や冗談を交わすようになる。1000XPを超えると、運命共同体としての深い絆を見せる。",
role: "オペレーター兼戦術指揮官"
};
// --- ゲーム状態と機体データ ---
let gameState = {
pilotName: '',
commanderName: '',
worldSetting: '',
currentAircraft: 'A-4 Skyhawk',
money: 2000,
xp: 0,
missionLevel: 1,
missionCount: 0,
isFinalMissionAvailable: false,
currentMission: null,
chatHistory: [],
};
// 機体データを大幅に追加・調整
const AIRCRAFT_DATA = [
// 初期~低コスト帯
{ name: 'A-4 Skyhawk', display: 'A-4 スカイホーク', cost: 0, requiredXP: 0, performance: 'C' },
{ name: 'A-1J Skyraider', display: 'A-1J スカイレイダー', cost: 15000, requiredXP: 50, performance: 'C+' },
{ name: 'F-5E Tiger II', display: 'F-5E タイガーII', cost: 25000, requiredXP: 200, performance: 'C+' },
// 中盤~ベテラン帯
{ name: 'F-8E Crusader', display: 'F-8E クルセイダー', cost: 40000, requiredXP: 400, performance: 'B' },
{ name: 'A-7 Corsair II', display: 'A-7 コルセアII', cost: 45000, requiredXP: 450, performance: 'B' },
{ name: 'F-4 Phantom II', display: 'F-4 ファントム II', cost: 50000, requiredXP: 500, performance: 'B' },
{ name: 'A-6 Intruder', display: 'A-6 イントルーダー', cost: 60000, requiredXP: 600, performance: 'B+' },
{ name: 'A-10 Thunderbolt II', display: 'A-10 サンダーボルトII', cost: 70000, requiredXP: 700, performance: 'B+' },
{ name: 'AV-8 Harrier II', display: 'AV-8 ハリアーII', cost: 80000, requiredXP: 800, performance: 'B+' },
{ name: 'F-16 Fighting Falcon', display: 'F-16 ファイティング・ファルコン', cost: 90000, requiredXP: 900, performance: 'A-' },
// 高性能・エース帯
{ name: 'F-14 Tomcat', display: 'F-14 トムキャット', cost: 100000, requiredXP: 1000, performance: 'A' },
{ name: 'FA-18 Hornet', display: 'F/A-18 ホーネット', cost: 120000, requiredXP: 1200, performance: 'A' },
{ name: 'F-15 Eagle', display: 'F-15 イーグル', cost: 150000, requiredXP: 1500, performance: 'A' },
// 特殊・戦略兵器帯
{ name: 'F-117 Nighthawk', display: 'F-117 ナイトホーク', cost: 180000, requiredXP: 1800, performance: 'A+' },
{ name: 'B-52 Stratofortress', display: 'B-52 ストラトフォートレス', cost: 200000, requiredXP: 2000, performance: 'A+' },
{ name: 'B-1 Lancer', display: 'B-1 ランサー', cost: 250000, requiredXP: 2200, performance: 'S-' },
// 最強・次世代帯
{ name: 'F-35 Lightning II', display: 'F-35 ライトニングII', cost: 350000, requiredXP: 2500, performance: 'S' },
{ name: 'F-22 Raptor', display: 'F-22 ラプター', cost: 400000, requiredXP: 2800, performance: 'S' },
// 最終目標
{ name: 'Final Machine', display: '??? (最終目標)', cost: 500000, requiredXP: 3000, performance: 'S' }
];
// --- DOM要素 ---
const logDisplay = document.getElementById('log-display');
const startMissionBtn = document.getElementById('start-mission-btn');
const confirmMissionBtn = document.getElementById('confirm-mission-btn');
const nameConfigBtn = document.getElementById('name-config-btn');
const hangarBtn = document.getElementById('hangar-btn');
const finalMissionBtn = document.getElementById('final-mission-btn');
const loadingOverlay = document.getElementById('loading-overlay');
const briefingArea = document.getElementById('mission-briefing-area');
const briefingTitle = document.getElementById('briefing-title');
const briefingContent = document.getElementById('briefing-content');
const chatLog = document.getElementById('chat-log');
const chatInput = document.getElementById('chat-input');
const chatSendBtn = document.getElementById('chat-send-btn');
const nameModal = document.getElementById('name-modal');
const inputWorldSetting = document.getElementById('input-world-setting');
const hangarModal = document.getElementById('hangar-modal');
const messageModal = document.getElementById('message-modal');
const closeNameModal = document.getElementById('close-name-modal');
const saveNameBtn = document.getElementById('save-name-btn');
const closeHangarModal = document.getElementById('close-hangar-modal');
const closeMessageModal = document.getElementById('close-message-modal');
const aircraftList = document.getElementById('aircraft-list');
// --- Firestore関連関数 ---
function getDocRef() {
if (!window.firebase.db || !window.firebase.userId) {
console.error("Firebase/UserId not initialized.");
return null;
}
const userId = window.firebase.userId;
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
return doc(window.firebase.db, 'artifacts', appId, 'users', userId, 'merc_game', 'state');
}
async function saveGameState() {
const docRef = getDocRef();
if (docRef) {
try {
const stateToSave = { ...gameState, currentMission: null, chatHistory: [] };
await setDoc(docRef, stateToSave);
console.log("Game state saved successfully.");
} catch (error) {
console.error("Error saving game state:", error);
}
}
}
function setupFirestoreListener() {
const docRef = getDocRef();
if (docRef) {
onSnapshot(docRef, (doc) => {
if (doc.exists()) {
const loadedState = doc.data();
const { currentMission, chatHistory, ...persistentState } = gameState;
gameState = { ...persistentState, ...loadedState };
if (gameState.worldSetting) {
inputWorldSetting.value = gameState.worldSetting;
}
updateUI();
console.log("Game state loaded/updated from Firestore.");
} else {
console.log("No saved data found. Starting new game.");
gameState.worldSetting = inputWorldSetting.value;
saveGameState();
}
startMissionBtn.disabled = !(gameState.pilotName && gameState.commanderName);
hangarBtn.disabled = !(gameState.pilotName && gameState.commanderName);
finalMissionBtn.disabled = !gameState.isFinalMissionAvailable;
confirmMissionBtn.disabled = true;
}, (error) => {
console.error("Error listening to game state:", error);
});
}
}
// --- UI更新関数 ---
function updateUI() {
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft) || AIRCRAFT_DATA[0];
document.getElementById('user-id-display').textContent = `ユーザーID: ${window.firebase.userId || '未認証'}`;
document.getElementById('pilot-display').textContent = gameState.pilotName ? `パイロット: ${gameState.pilotName}` : '未設定';
document.getElementById('commander-display').textContent = gameState.commanderName ? `司令官: ${gameState.commanderName}` : '未設定';
document.getElementById('pilot-name-display').textContent = gameState.pilotName || '---';
document.getElementById('commander-name-display').textContent = gameState.commanderName || '---';
document.getElementById('aircraft-display').textContent = currentAircraftData.display;
document.getElementById('money-display').textContent = gameState.money.toLocaleString();
document.getElementById('xp-display').textContent = gameState.xp.toLocaleString();
startMissionBtn.disabled = !(gameState.pilotName && gameState.commanderName);
hangarBtn.disabled = !(gameState.pilotName && gameState.commanderName);
finalMissionBtn.disabled = !gameState.isFinalMissionAvailable;
if (gameState.xp >= 2500 && gameState.money >= 300000 && !gameState.isFinalMissionAvailable) {
gameState.isFinalMissionAvailable = true;
finalMissionBtn.disabled = false;
appendLog(`[${gameState.commanderName}]:ついに時が来たぞ、${gameState.pilotName}。最終目標「プロジェクト・ロンギヌス」の実行許可が下りた。`, 'text-purple-400');
saveGameState();
}
if (!gameState.currentMission) {
briefingArea.classList.add('hidden');
confirmMissionBtn.disabled = true;
chatInput.disabled = true;
chatSendBtn.disabled = true;
}
}
function appendLog(message, colorClass = 'text-gray-300') {
const timestamp = new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
const logEntry = document.createElement('p');
logEntry.className = `mt-1 ${colorClass}`;
logEntry.textContent = `[${timestamp}] ${message}`;
logDisplay.appendChild(logEntry);
logDisplay.scrollTop = logDisplay.scrollHeight;
}
function showAlert(title, content) {
document.getElementById('message-title').textContent = title;
document.getElementById('message-content').textContent = content;
messageModal.classList.remove('hidden');
messageModal.classList.add('flex');
}
// --- チャットUI関連関数 ---
function appendChatMessage(role, text) {
const messageElement = document.createElement('div');
const roleText = role === 'commander' ? gameState.commanderName : gameState.pilotName;
const messageClass = role === 'commander' ? 'message-commander' : 'message-pilot';
messageElement.className = `max-w-[80%] p-3 text-sm text-white shadow-md ${messageClass}`;
messageElement.innerHTML = `<span class="font-bold text-xs ${role === 'commander' ? 'text-green-300' : 'text-blue-300'}">${roleText}:</span> ${text}`;
chatLog.appendChild(messageElement);
chatLog.scrollTop = chatLog.scrollHeight;
}
function renderChatHistory() {
chatLog.innerHTML = '';
gameState.chatHistory.forEach(msg => {
appendChatMessage(msg.role, msg.text);
});
}
// --- LLM API関連関数 ---
async function callGeminiApi(payload, maxRetries = 5, isJson = false, schema = null) {
const url = API_URL + API_KEY;
let attempt = 0;
while (attempt < maxRetries) {
try {
if (isJson && schema) {
payload.generationConfig = {
responseMimeType: "application/json",
responseSchema: schema
};
} else if (payload.generationConfig) {
delete payload.generationConfig;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 429) {
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
attempt++;
continue;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const candidate = result.candidates?.[0];
if (candidate && candidate.content?.parts?.[0]?.text) {
const text = candidate.content.parts[0].text;
if (isJson) {
try {
return JSON.parse(text);
} catch (e) {
console.error("Failed to parse JSON from LLM response:", text);
throw new Error("Invalid JSON response from LLM.");
}
}
return text;
} else {
throw new Error("No valid content found in LLM response.");
}
} catch (error) {
console.error(`Attempt ${attempt + 1} failed:`, error);
attempt++;
if (attempt >= maxRetries) {
throw new Error("API call failed after multiple retries.");
}
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// --- システムプロンプト生成ヘルパー ---
function createBaseSystemPrompt() {
return `
あなたは傭兵部隊の司令官「${gameState.commanderName}」です。
【司令官プロファイル】
- 性別: ${COMMANDER_PROFILE.gender}
- 外見/イメージ: ${COMMANDER_PROFILE.appearance}
- 性格: ${COMMANDER_PROFILE.personality}
- 口調: ${COMMANDER_PROFILE.tone}
- パイロットとの関係性: ${COMMANDER_PROFILE.relationshipGrowth}
- 現在のパイロットの経験値(XP): ${gameState.xp} (この値が高いほど信頼関係は深いです)
【世界設定】
${gameState.worldSetting}
あなたはパイロット「${gameState.pilotName}」のパートナーとして振る舞ってください。
`;
}
// --- ミッション生成・ブリーフィング表示 ---
async function generateMission() {
loadingOverlay.classList.remove('hidden');
startMissionBtn.disabled = true;
hangarBtn.disabled = true;
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
${createBaseSystemPrompt()}
現在の難易度レベルに基づいて、簡潔なミッションを提示してください。
ミッションの難易度はレベル${gameState.missionLevel}です。
現在の機体は「${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})」です。
結果を以下のJSONスキーマに従って日本語で、感情を込めて出力してください。
`;
const userQuery = gameState.isFinalMissionAvailable
? `最終目標「プロジェクト・ロンギヌス」に向けた、極秘で危険度の高い重要なミッションを提案せよ。`
: `現在の機体「${currentAircraftData.display}」で実行可能な、レベル${gameState.missionLevel}のランダムなミッション(偵察、迎撃、護衛、対地攻撃など)を提案せよ。`;
const missionSchema = {
type: "OBJECT",
properties: {
missionTitle: { "type": "STRING", "description": "ミッションの簡潔なタイトル" },
briefing: { "type": "STRING", "description": "司令官からの詳細なブリーフィング(世界設定を反映させること)" },
baseRewardMoney: { "type": "INTEGER", "description": "成功時の基本報酬額(難易度レベルに応じて、5000~50000程度)" },
baseRewardXP: { "type": "INTEGER", "description": "成功時の基本獲得経験値(難易度レベルに応じて、50~500程度)" },
difficultyRating: { "type": "STRING", "description": "ミッションの難易度(例: E, D, C, B, A, S)" }
},
required: ["missionTitle", "briefing", "baseRewardMoney", "baseRewardXP", "difficultyRating"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
const missionData = await callGeminiApi(payload, 5, true, missionSchema);
gameState.currentMission = missionData;
gameState.chatHistory = [];
briefingTitle.textContent = `ミッション名: ${missionData.missionTitle} (難易度: ${missionData.difficultyRating})`;
briefingContent.innerHTML = `
<p class="text-sm text-gray-400">レベル: ${gameState.missionLevel} / 機体: ${currentAircraftData.display}</p>
<p class="text-sm text-gray-400 mb-2">基本報酬: ${missionData.baseRewardMoney.toLocaleString()}$ / 基本XP: ${missionData.baseRewardXP} XP</p>
<p class="mt-2 text-lg whitespace-pre-wrap"><span class="font-bold text-green-300">${gameState.commanderName}:</span>${missionData.briefing}</p>
`;
briefingArea.classList.remove('hidden');
const initialMessage = `${gameState.pilotName}、ブリーフィングは確認した?不明点があれば聞いて。準備ができたら出撃よ。`;
gameState.chatHistory.push({ role: "commander", text: initialMessage });
renderChatHistory();
confirmMissionBtn.disabled = false;
chatInput.disabled = false;
chatSendBtn.disabled = false;
startMissionBtn.disabled = true;
chatInput.focus();
appendLog(`[システム]:新しいミッション「${missionData.missionTitle}」のブリーフィングを受信しました。`, 'text-yellow-400');
} catch (error) {
console.error("Mission generation failed:", error);
showAlert("エラー", "ブリーフィング生成に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
document.querySelector('#loading-overlay p').textContent = '通信中... ブリーフィングを受信しています';
hangarBtn.disabled = false;
}
}
// --- チャット処理 ---
async function handleChat() {
const userText = chatInput.value.trim();
if (!userText || !gameState.currentMission) return;
gameState.chatHistory.push({ role: "pilot", text: userText });
appendChatMessage("pilot", userText);
chatInput.value = '';
chatInput.disabled = true;
chatSendBtn.disabled = true;
const chatSystemPrompt = `
${createBaseSystemPrompt()}
現在のミッションは「${gameState.currentMission.missionTitle}」です。
パイロットからの質問や意見に対し、プロファイル設定(${COMMANDER_PROFILE.personality})に基づいて応答してください。
現在の経験値は${gameState.xp}です。経験値が高い場合、より親密な信頼関係を示してください。
以下の過去の会話履歴に基づき、簡潔に日本語で応答してください。
`;
const contents = gameState.chatHistory.map(msg => ({
role: msg.role === 'pilot' ? 'user' : 'model',
parts: [{ text: msg.text }]
}));
const payload = {
contents: contents,
systemInstruction: { parts: [{ text: chatSystemPrompt }] },
};
try {
const commanderResponse = await callGeminiApi(payload, 5, false);
gameState.chatHistory.push({ role: "commander", text: commanderResponse });
appendChatMessage("commander", commanderResponse);
} catch (error) {
console.error("Chat generation failed:", error);
const fallbackMessage = "通信ノイズが入ったわ。もう一度頼める?";
gameState.chatHistory.push({ role: "commander", text: fallbackMessage });
appendChatMessage("commander", fallbackMessage);
} finally {
chatInput.disabled = false;
chatSendBtn.disabled = false;
chatInput.focus();
}
}
// --- ミッション実行 ---
async function executeMission() {
if (!gameState.currentMission) {
showAlert("エラー", "実行するミッションブリーフィングがありません。");
return;
}
loadingOverlay.classList.remove('hidden');
document.querySelector('#loading-overlay p').textContent = '作戦実行中... 結果を分析しています';
confirmMissionBtn.disabled = true;
startMissionBtn.disabled = true;
chatInput.disabled = true;
chatSendBtn.disabled = true;
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const missionData = gameState.currentMission;
const chatSummary = gameState.chatHistory.map(msg => `${msg.role === 'pilot' ? gameState.pilotName : gameState.commanderName}: ${msg.text}`).join('\n');
const systemPrompt = `
あなたは空戦の専門家であり、この作戦の評価官です。
【世界設定】
${gameState.worldSetting}
パイロット「${gameState.pilotName}」のミッション結果を、機体の性能、ミッション難易度、そして**司令官との事前のチャット内容**を総合的に考慮して判定してください。
チャット履歴におけるパイロットの態度や準備状況は、成功率に影響します。
結果を物語風に記述し、報酬を計算して、以下のJSONスキーマに従って出力してください。
`;
const userQuery = `
ミッション: ${missionData.missionTitle} (難易度: ${missionData.difficultyRating} / レベル: ${gameState.missionLevel})
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
事前の司令官とのチャット履歴:
---
${chatSummary}
---
この条件で、作戦の結果(成否、物語、獲得資金、獲得XP)を判定してください。
`;
const resultSchema = {
type: "OBJECT",
properties: {
success: { "type": "BOOLEAN", "description": "ミッションの成否" },
narration: { "type": "STRING", "description": "戦闘結果の物語風な描写" },
moneyEarned: { "type": "INTEGER", "description": "獲得した資金(失敗時は0またはマイナス)" },
xpEarned: { "type": "INTEGER", "description": "獲得した経験値(失敗時は少ない)" }
},
required: ["success", "narration", "moneyEarned", "xpEarned"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
const resultData = await callGeminiApi(payload, 5, true, resultSchema);
gameState.money += resultData.moneyEarned;
gameState.xp += resultData.xpEarned;
gameState.missionCount += 1;
if (gameState.missionCount > 0 && gameState.missionCount % 5 === 0) {
gameState.missionLevel += 1;
appendLog(`難易度レベルが上昇しました!現在のレベル: ${gameState.missionLevel}`, 'text-yellow-500 font-bold');
}
// --- 撃墜(ミッション失敗)時の機体ペナルティロジック ---
if (!resultData.success) {
const repairCost = Math.floor(gameState.money * 0.1) + 1000; // 資金の10% + 1000ドルを修理費とする
if (gameState.currentAircraft !== 'A-4 Skyhawk') {
// A-4以外の機体だった場合、A-4に戻す
const lostAircraftDisplay = currentAircraftData.display;
gameState.money = Math.max(0, gameState.money - repairCost); // 資金がマイナスにならないように
gameState.currentAircraft = 'A-4 Skyhawk'; // 初期機体に戻す
appendLog(`[警報]:現在の機体「${lostAircraftDisplay}」は戦闘不能!「A-4 スカイホーク」に強制換装し、修理費として ${repairCost.toLocaleString()}$ が差し引かれました。`, 'text-red-600 font-extrabold');
showAlert("機体喪失", `「${lostAircraftDisplay}」は戦闘不能と判定され、初期機体「A-4 スカイホーク」に戻されました。修理費用として ${repairCost.toLocaleString()}$ が差し引かれました。`);
} else {
// A-4だった場合、資金のみのペナルティ
const minRepairCost = 500;
gameState.money = Math.max(0, gameState.money - minRepairCost); // 最小限の修理費
appendLog(`[警告]:A-4 スカイホークが損傷。修理費として ${minRepairCost}$ が差し引かれました。`, 'text-red-500');
}
}
// ---------------------------------------------------
appendLog(`\n--- ミッション結果: ${missionData.missionTitle} ---`, 'text-white font-bold');
appendLog(`[評価官]:${resultData.narration}`, resultData.success ? 'text-blue-300' : 'text-red-400');
appendLog(`[結果]:${resultData.success ? '成功' : '失敗'}`, resultData.success ? 'text-green-500' : 'text-red-500');
appendLog(`[獲得]:資金 ${resultData.moneyEarned.toLocaleString()}$ / 経験値 ${resultData.xpEarned} XP`, 'text-cyan-400');
appendLog(`[${gameState.commanderName}]:作戦終了。帰投を確認。`, 'text-pink-400');
gameState.currentMission = null;
gameState.chatHistory = [];
briefingArea.classList.add('hidden');
startMissionBtn.disabled = false;
await saveGameState();
updateUI();
} catch (error) {
console.error("Mission execution failed:", error);
showAlert("エラー", "ミッション結果の判定に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
document.querySelector('#loading-overlay p').textContent = '通信中... ブリーフィングを受信しています';
hangarBtn.disabled = false;
}
}
// --- 最終ミッション実行 ---
async function executeFinalMission() {
if (!gameState.isFinalMissionAvailable) {
showAlert("通知", "最終目標の準備が整っていません。さらに多くのミッションをこなし、経験値を積んでください。");
return;
}
loadingOverlay.classList.remove('hidden');
document.querySelector('#loading-overlay p').textContent = '最終目標作戦実行中...';
startMissionBtn.disabled = true;
hangarBtn.disabled = true;
finalMissionBtn.disabled = true;
confirmMissionBtn.disabled = true;
briefingArea.classList.add('hidden');
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
あなたは空戦の専門家であり、最終目標「プロジェクト・ロンギヌス」作戦の評価官です。
【世界設定】
${gameState.worldSetting}
パイロット「${gameState.pilotName}」の作戦結果を、機体「${currentAircraftData.display}」の性能と、これまでの彼の功績を考慮して判定してください。
結果は、大勝利、勝利、または作戦失敗のいずれかです。
結果を物語風に記述し、ゲームの最終的な結末として提示してください。
結果はJSONスキーマに従って出力してください。
`;
const userQuery = `
最終目標: プロジェクト・ロンギヌス
パイロット: ${gameState.pilotName}
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
これまでの総経験値: ${gameState.xp.toLocaleString()} XP
この条件で、作戦の結果(成否、物語)を判定してください。最終ミッションのため、報酬の計算は不要です。
`;
const finalResultSchema = {
type: "OBJECT",
properties: {
success: { "type": "BOOLEAN", "description": "最終目標の成否" },
ending: { "type": "STRING", "description": "ゲームの最終的な結末となる物語" }
},
required: ["success", "ending"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
const resultData = await callGeminiApi(payload, 5, true, finalResultSchema);
appendLog(`\n--- 最終目標「プロジェクト・ロンギヌス」結果 ---`, 'text-red-500 font-extrabold text-lg');
appendLog(`[結末]:${resultData.ending}`, resultData.success ? 'text-yellow-200' : 'text-red-400');
appendLog(`ゲーム終了。${gameState.pilotName}の傭兵としての伝説はここで幕を閉じる...`, 'text-white font-bold');
startMissionBtn.disabled = true;
finalMissionBtn.disabled = true;
hangarBtn.disabled = true;
gameState.isFinalMissionAvailable = false;
await saveGameState();
} catch (error) {
console.error("Final mission execution failed:", error);
showAlert("エラー", "最終目標の判定に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
}
}
// --- 格納庫 (機体交換) ロジック ---
function openHangar() {
document.getElementById('hangar-money-display').textContent = gameState.money.toLocaleString();
aircraftList.innerHTML = '';
AIRCRAFT_DATA.forEach(aircraft => {
const isCurrent = aircraft.name === gameState.currentAircraft;
const canAfford = gameState.money >= aircraft.cost;
const meetsXP = gameState.xp >= aircraft.requiredXP;
const canPurchase = !isCurrent && canAfford && meetsXP;
const cardHtml = `
<div class="p-4 rounded-lg border ${isCurrent ? 'border-yellow-500 bg-yellow-900/20' : 'border-[#444c56] bg-[#30363d]'}">
<h4 class="text-xl font-bold ${isCurrent ? 'text-yellow-300' : 'text-white'}">${aircraft.display}</h4>
<p class="text-sm text-gray-400 mt-1">性能ランク: <span class="font-bold">${aircraft.performance}</span></p>
<p class="text-sm text-gray-400">必要資金: ${aircraft.cost.toLocaleString()}$ / 必要XP: ${aircraft.requiredXP.toLocaleString()} XP</p>
<div class="mt-3">
${isCurrent
? `<button class="w-full bg-gray-500 text-white font-bold py-2 px-4 rounded-lg cursor-default" disabled>現在搭乗中</button>`
: canPurchase
? `<button class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg purchase-btn" data-aircraft-name="${aircraft.name}">乗り換え</button>`
: `<button class="w-full bg-red-800 text-white font-bold py-2 px-4 rounded-lg cursor-default" disabled>
${!canAfford ? '資金不足' : !meetsXP ? 'XP不足' : '購入不可'}
</button>`
}
</div>
</div>
`;
aircraftList.insertAdjacentHTML('beforeend', cardHtml);
});
aircraftList.querySelectorAll('.purchase-btn').forEach(button => {
button.addEventListener('click', (e) => {
const newAircraftName = e.target.getAttribute('data-aircraft-name');
purchaseAircraft(newAircraftName);
});
});
hangarModal.classList.remove('hidden');
hangarModal.classList.add('flex');
}
async function purchaseAircraft(newAircraftName) {
const newAircraftData = AIRCRAFT_DATA.find(a => a.name === newAircraftName);
if (!newAircraftData) return;
if (gameState.money >= newAircraftData.cost) {
gameState.money -= newAircraftData.cost;
gameState.currentAircraft = newAircraftName;
appendLog(`[システム]:新しい機体「${newAircraftData.display}」に乗り換えました。資金を${newAircraftData.cost.toLocaleString()}$消費。`, 'text-yellow-500');
await saveGameState();
updateUI();
openHangar();
showAlert("乗り換え完了", `「${newAircraftData.display}」への乗り換えが完了しました。新たな機体で任務に挑んでください!`);
} else {
showAlert("資金不足", `${newAircraftData.display}を購入するための資金が不足しています。`);
}
}
// --- イベントリスナー設定と初期化 ---
function setupGame() {
setupFirestoreListener();
nameConfigBtn.addEventListener('click', () => {
document.getElementById('input-pilot-name').value = gameState.pilotName;
document.getElementById('input-commander-name').value = gameState.commanderName;
if (gameState.worldSetting) {
inputWorldSetting.value = gameState.worldSetting;
}
nameModal.classList.remove('hidden');
nameModal.classList.add('flex');
});
closeNameModal.addEventListener('click', () => {
nameModal.classList.remove('flex');
nameModal.classList.add('hidden');
});
saveNameBtn.addEventListener('click', async () => {
const pilotName = document.getElementById('input-pilot-name').value.trim();
const commanderName = document.getElementById('input-commander-name').value.trim();
const worldSetting = inputWorldSetting.value.trim();
if (pilotName && commanderName) {
const isNewGame = !(gameState.pilotName && gameState.commanderName);
gameState.pilotName = pilotName;
gameState.commanderName = commanderName;
gameState.worldSetting = worldSetting;
await saveGameState();
updateUI();
nameModal.classList.remove('flex');
nameModal.classList.add('hidden');
if (isNewGame) {
appendLog(`[${gameState.commanderName}]:ようこそ、${gameState.pilotName}。これが君の愛機、A-4 スカイホークだ。ここから伝説を築き上げろ。`, 'text-green-500');
showAlert("開始", `${gameState.pilotName} パイロットと ${gameState.commanderName} 司令官の名前が設定されました。ブリーフィングを表示してミッションを開始しましょう!`);
} else {
showAlert("更新完了", "設定が更新されました。");
}
} else {
showAlert("入力エラー", "パイロット名と司令官名の両方を入力してください。");
}
});
startMissionBtn.addEventListener('click', generateMission);
confirmMissionBtn.addEventListener('click', executeMission);
chatSendBtn.addEventListener('click', handleChat);
chatInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleChat();
e.preventDefault();
}
});
hangarBtn.addEventListener('click', openHangar);
closeHangarModal.addEventListener('click', () => {
hangarModal.classList.remove('flex');
hangarModal.classList.add('hidden');
});
finalMissionBtn.addEventListener('click', executeFinalMission);
closeMessageModal.addEventListener('click', () => {
messageModal.classList.remove('flex');
messageModal.classList.add('hidden');
});
}
</script>
</body>
</html>
Ver.082 ブリーフィングをチャット可能へ激烈変化(笑)
もうTRPGのりへ特化を始める、寧ろぶっ飛んでて好き。
果たして司令官を口説いて上官侮辱罪で銃殺されるかは試してない。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大空の傭兵シミュレーター(対話版)</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Interフォントをロード -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<!-- Firebase SDKのインポート (コアサービスとAuthのみ) -->
<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";
// 修正: ユーティリティ関数 (doc, setDoc, getDoc, onSnapshot) は下のブロックに移動
import { getFirestore, setLogLevel } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// Firestoreのログレベルを設定 (デバッグ用)
setLogLevel('Debug');
// グローバル変数としてFirestore, Authインスタンスを公開
window.firebase = {
db: null,
auth: null,
userId: null,
isAuthReady: false
};
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = JSON.parse(typeof __firebase_config !== 'undefined' ? __firebase_config : '{}');
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
if (Object.keys(firebaseConfig).length > 0) {
const app = initializeApp(firebaseConfig);
window.firebase.db = getFirestore(app);
window.firebase.auth = getAuth(app);
onAuthStateChanged(window.firebase.auth, async (user) => {
if (user) {
window.firebase.userId = user.uid;
} else {
// 認証されていない場合、カスタムトークンがあればサインインを試みる
if (initialAuthToken) {
try {
await signInWithCustomToken(window.firebase.auth, initialAuthToken);
} catch (error) {
console.error("Custom token sign-in failed:", error);
// 失敗しても匿名サインインを試みる
await signInAnonymously(window.firebase.auth);
}
} else {
// トークンがなければ匿名サインイン
await signInAnonymously(window.firebase.auth);
}
// 認証後のユーザーIDを取得。匿名サインインの場合もuidが設定される。
window.firebase.userId = window.firebase.auth.currentUser?.uid || crypto.randomUUID();
}
window.firebase.isAuthReady = true;
document.dispatchEvent(new CustomEvent('firebaseAuthReady'));
});
} else {
console.error("Firebase configuration is missing.");
}
</script>
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #0d1117;
color: #c9d1d9;
}
.scroll-container {
overflow-y: auto;
}
.scroll-container::-webkit-scrollbar {
width: 8px;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: #30363d;
border-radius: 4px;
}
.scroll-container::-webkit-scrollbar-track {
background: #161b22;
}
.chat-log {
height: 350px;
background-color: #161b22;
border-top: 2px solid #30363d;
}
.message-commander {
align-self: flex-start;
background-color: #213c32; /* Greenish background for Commander */
border-radius: 12px 12px 12px 0;
}
.message-pilot {
align-self: flex-end;
background-color: #293855; /* Bluish background for Pilot */
border-radius: 12px 12px 0 12px;
}
</style>
</head>
<body class="p-4 sm:p-6 lg:p-8">
<div id="loading-overlay" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 transition-opacity duration-300 hidden">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto"></div>
<p class="mt-4 text-lg font-semibold text-green-400">通信中... ブリーフィングを受信しています</p>
</div>
</div>
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold mb-6 text-green-400">大空の傭兵 - The Sky Mercenary</h1>
<p id="user-id-display" class="text-sm text-gray-500 mb-4">ユーザーID: Loading...</p>
<!-- ステータス表示カード -->
<div class="bg-[#161b22] p-6 rounded-xl shadow-lg mb-6 border border-[#30363d] grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-400">パイロット名 (<span id="pilot-display">未設定</span>)</p>
<h2 id="pilot-name-display" class="text-2xl font-semibold text-white">---</h2>
<p class="text-sm text-gray-400 mt-2">司令官名 (<span id="commander-display">未設定</span>)</p>
<h2 id="commander-name-display" class="text-xl font-semibold text-gray-300">---</h2>
</div>
<div>
<p class="text-sm text-gray-400">現在の機体</p>
<h2 id="aircraft-display" class="text-3xl font-bold text-yellow-300">A-4 スカイホーク</h2>
<div class="mt-2 text-lg">
<p>資金: <span id="money-display" class="font-bold text-green-500">0</span> $</p>
<p>経験値: <span id="xp-display" class="font-bold text-blue-400">0</span> XP</p>
</div>
</div>
</div>
<!-- アクションと設定セクション -->
<div class="mb-6 grid grid-cols-1 sm:grid-cols-4 gap-4">
<button id="start-mission-btn" class="col-span-2 sm:col-span-2 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
<i class="fas fa-satellite-dish"></i> ブリーフィングを表示
</button>
<button id="confirm-mission-btn" class="col-span-2 sm:col-span-2 bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
<i class="fas fa-fighter-jet"></i> ミッション開始
</button>
<button id="name-config-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200">
名前を設定/変更
</button>
<button id="hangar-btn" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
格納庫 (機体交換)
</button>
<button id="final-mission-btn" class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50 col-span-2" disabled>
最終目標へ
</button>
</div>
<!-- ブリーフィング&チャットエリア -->
<div id="mission-briefing-area" class="bg-[#161b22] p-6 rounded-xl shadow-lg border border-[#30363d] mb-6 hidden">
<h2 id="briefing-title" class="text-2xl font-bold mb-3 text-yellow-400">ミッション・タイトル</h2>
<div id="briefing-content" class="text-gray-300 whitespace-pre-wrap mb-4 border-b border-[#30363d] pb-4">
<!-- ブリーフィング詳細がここに挿入されます -->
</div>
<h3 class="text-xl font-semibold mb-3 text-white">司令官とのチャット</h3>
<div id="chat-log" class="chat-log scroll-container p-3 flex flex-col space-y-3">
<!-- チャットメッセージがここに挿入されます -->
</div>
<!-- チャット入力フォーム -->
<div id="chat-container" class="mt-4 flex space-x-2">
<input type="text" id="chat-input" class="flex-grow p-3 rounded-lg bg-[#30363d] border border-[#444c56] text-white focus:outline-none focus:border-green-500" placeholder="司令官に質問や確認事項を入力..." disabled>
<button id="chat-send-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition duration-200 disabled:opacity-50" disabled>
送信
</button>
</div>
</div>
<!-- 司令部ログ表示エリア (ゲームの履歴) -->
<div class="bg-[#161b22] p-6 rounded-xl shadow-lg border border-[#30363d]">
<h2 class="text-xl font-semibold mb-3 text-white">司令部ログ (履歴)</h2>
<div id="log-display" class="scroll-container h-48 p-3 text-sm text-gray-300 whitespace-pre-wrap">
> ログがここに表示されます。まずは「名前を設定/変更」から開始してください。
</div>
</div>
<!-- モーダルウィンドウ (名前設定) - (省略: 前回コードから変更なし) -->
<div id="name-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-md border border-[#30363d]">
<h3 class="text-2xl font-bold mb-4 text-green-400">名前の設定</h3>
<div class="mb-4">
<label for="input-pilot-name" class="block text-sm font-medium mb-1">パイロット名 (主人公)</label>
<input type="text" id="input-pilot-name" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white" placeholder="例: アルフレッド">
</div>
<div class="mb-6">
<label for="input-commander-name" class="block text-sm font-medium mb-1">司令官名</label>
<input type="text" id="input-commander-name" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white" placeholder="例: ミリィ">
</div>
<div class="flex justify-end space-x-3">
<button id="save-name-btn" class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg">
保存して開始
</button>
<button id="close-name-modal" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg">
キャンセル
</button>
</div>
</div>
</div>
<!-- モーダルウィンドウ (格納庫/機体交換) - (省略: 前回コードから変更なし) -->
<div id="hangar-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-lg border border-[#30363d]">
<h3 class="text-2xl font-bold mb-4 text-yellow-400">格納庫 (機体交換)</h3>
<p class="mb-4 text-sm text-gray-400">現在の資金: <span id="hangar-money-display" class="font-bold text-green-500">0</span> $</p>
<div id="aircraft-list" class="space-y-4">
<!-- 機体カードがここに挿入される -->
</div>
<div class="flex justify-end mt-6">
<button id="close-hangar-modal" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg">
閉じる
</button>
</div>
</div>
</div>
<!-- モーダルウィンドウ (メッセージ/アラート) - (省略: 前回コードから変更なし) -->
<div id="message-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-sm border border-[#30363d]">
<h3 id="message-title" class="text-2xl font-bold mb-4 text-white">通知</h3>
<p id="message-content" class="mb-6 text-gray-300">メッセージ内容</p>
<div class="flex justify-end">
<button id="close-message-modal" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg">
OK
</button>
</div>
</div>
</div>
</div>
<script type="module">
// 修正: Firestoreユーティリティ関数をここでインポートし、スコープ内で利用可能にする
import { doc, setDoc, getDoc, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// Firebase初期化を待機
document.addEventListener('firebaseAuthReady', setupGame);
const API_KEY = "";
const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=";
// --- ゲーム状態と機体データ ---
let gameState = {
pilotName: '',
commanderName: '',
currentAircraft: 'A-4 Skyhawk',
money: 0,
xp: 0,
missionLevel: 1,
missionCount: 0,
isFinalMissionAvailable: false,
currentMission: null, // 新規: 現在のミッションデータ
chatHistory: [], // 新規: ブリーフィングに関するチャット履歴
};
const AIRCRAFT_DATA = [
{ name: 'A-4 Skyhawk', display: 'A-4 スカイホーク', cost: 0, requiredXP: 0, performance: 'C' },
{ name: 'F-4 Phantom II', display: 'F-4 ファントム II', cost: 50000, requiredXP: 500, performance: 'B' },
{ name: 'F-15 Eagle', display: 'F-15 イーグル', cost: 150000, requiredXP: 1500, performance: 'A' },
{ name: 'Final Machine', display: '??? (最終目標)', cost: 500000, requiredXP: 3000, performance: 'S' }
];
// --- DOM要素 ---
const logDisplay = document.getElementById('log-display');
const startMissionBtn = document.getElementById('start-mission-btn'); // 「ブリーフィングを表示」ボタン
const confirmMissionBtn = document.getElementById('confirm-mission-btn'); // 「ミッション開始」ボタン
const nameConfigBtn = document.getElementById('name-config-btn');
const hangarBtn = document.getElementById('hangar-btn');
const finalMissionBtn = document.getElementById('final-mission-btn');
const loadingOverlay = document.getElementById('loading-overlay');
// 新規/変更されたDOM要素
const briefingArea = document.getElementById('mission-briefing-area');
const briefingTitle = document.getElementById('briefing-title');
const briefingContent = document.getElementById('briefing-content');
const chatLog = document.getElementById('chat-log');
const chatInput = document.getElementById('chat-input');
const chatSendBtn = document.getElementById('chat-send-btn');
// モーダル要素 (省略: 変更なし)
const nameModal = document.getElementById('name-modal');
const hangarModal = document.getElementById('hangar-modal');
const messageModal = document.getElementById('message-modal');
const closeNameModal = document.getElementById('close-name-modal');
const saveNameBtn = document.getElementById('save-name-btn');
const closeHangarModal = document.getElementById('close-hangar-modal');
const closeMessageModal = document.getElementById('close-message-modal');
const aircraftList = document.getElementById('aircraft-list');
// --- Firestore関連関数 (変更なし) ---
function getDocRef() {
if (!window.firebase.db || !window.firebase.userId) {
console.error("Firebase/UserId not initialized.");
return null;
}
const userId = window.firebase.userId;
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
return doc(window.firebase.db, 'artifacts', appId, 'users', userId, 'merc_game', 'state');
}
async function saveGameState() {
const docRef = getDocRef();
if (docRef) {
try {
// LLMとの会話履歴は一時的なものなので、永続化しない
const stateToSave = { ...gameState, currentMission: null, chatHistory: [] };
await setDoc(docRef, stateToSave);
console.log("Game state saved successfully.");
} catch (error) {
console.error("Error saving game state:", error);
}
}
}
function setupFirestoreListener() {
const docRef = getDocRef();
if (docRef) {
onSnapshot(docRef, (doc) => {
if (doc.exists()) {
const loadedState = doc.data();
// 永続化しない一時的な状態は復元時に無視する
const { currentMission, chatHistory, ...persistentState } = gameState;
gameState = { ...persistentState, ...loadedState };
updateUI();
console.log("Game state loaded/updated from Firestore.");
} else {
console.log("No saved data found. Starting new game.");
saveGameState();
}
startMissionBtn.disabled = !(gameState.pilotName && gameState.commanderName);
hangarBtn.disabled = !(gameState.pilotName && gameState.commanderName);
finalMissionBtn.disabled = !gameState.isFinalMissionAvailable;
// ミッション開始ボタンはブリーフィング表示後に有効化される
confirmMissionBtn.disabled = true;
}, (error) => {
console.error("Error listening to game state:", error);
});
}
}
// --- UI更新関数 (変更あり) ---
function updateUI() {
// ... (前回と同様のUI更新ロジック) ...
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft) || AIRCRAFT_DATA[0];
document.getElementById('user-id-display').textContent = `ユーザーID: ${window.firebase.userId || '未認証'}`;
document.getElementById('pilot-display').textContent = gameState.pilotName ? `パイロット: ${gameState.pilotName}` : '未設定';
document.getElementById('commander-display').textContent = gameState.commanderName ? `司令官: ${gameState.commanderName}` : '未設定';
document.getElementById('pilot-name-display').textContent = gameState.pilotName || '---';
document.getElementById('commander-name-display').textContent = gameState.commanderName || '---';
document.getElementById('aircraft-display').textContent = currentAircraftData.display;
document.getElementById('money-display').textContent = gameState.money.toLocaleString();
document.getElementById('xp-display').textContent = gameState.xp.toLocaleString();
startMissionBtn.disabled = !(gameState.pilotName && gameState.commanderName);
hangarBtn.disabled = !(gameState.pilotName && gameState.commanderName);
finalMissionBtn.disabled = !gameState.isFinalMissionAvailable;
if (gameState.xp >= 2500 && gameState.money >= 300000 && !gameState.isFinalMissionAvailable) {
gameState.isFinalMissionAvailable = true;
finalMissionBtn.disabled = false;
appendLog(`[${gameState.commanderName}]:ついに時が来たぞ、${gameState.pilotName}。最終目標「プロジェクト・ロンギヌス」の実行許可が下りた。`, 'text-purple-400');
saveGameState();
}
// ミッションが開始されていない場合はチャットUIを非表示
if (!gameState.currentMission) {
briefingArea.classList.add('hidden');
confirmMissionBtn.disabled = true;
chatInput.disabled = true;
chatSendBtn.disabled = true;
}
}
// --- ログ・アラート関数 (変更なし) ---
function appendLog(message, colorClass = 'text-gray-300') {
const timestamp = new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
const logEntry = document.createElement('p');
logEntry.className = `mt-1 ${colorClass}`;
logEntry.textContent = `[${timestamp}] ${message}`;
logDisplay.appendChild(logEntry);
logDisplay.scrollTop = logDisplay.scrollHeight;
}
function showAlert(title, content) {
document.getElementById('message-title').textContent = title;
document.getElementById('message-content').textContent = content;
messageModal.classList.remove('hidden');
messageModal.classList.add('flex');
}
// --- チャットUI関連関数 (新規) ---
/**
* チャットログにメッセージを追加
* @param {string} role - 'commander' or 'pilot'
* @param {string} text - メッセージ本文
*/
function appendChatMessage(role, text) {
const messageElement = document.createElement('div');
const roleText = role === 'commander' ? gameState.commanderName : gameState.pilotName;
const messageClass = role === 'commander' ? 'message-commander' : 'message-pilot';
messageElement.className = `max-w-[80%] p-3 text-sm text-white shadow-md ${messageClass}`;
messageElement.innerHTML = `<span class="font-bold text-xs ${role === 'commander' ? 'text-green-300' : 'text-blue-300'}">${roleText}:</span> ${text}`;
chatLog.appendChild(messageElement);
chatLog.scrollTop = chatLog.scrollHeight;
}
/**
* チャット履歴を更新し、UIに表示
*/
function renderChatHistory() {
chatLog.innerHTML = '';
gameState.chatHistory.forEach(msg => {
appendChatMessage(msg.role, msg.text);
});
}
// --- LLM API関連関数 ---
/**
* 指数バックオフ付きのLLM API呼び出し (JSONスキーマなしの汎用)
*/
async function callGeminiApi(payload, maxRetries = 5, isJson = false, schema = null) {
const url = API_URL + API_KEY;
let attempt = 0;
while (attempt < maxRetries) {
try {
// JSONスキーマがある場合は設定を追加
if (isJson && schema) {
payload.generationConfig = {
responseMimeType: "application/json",
responseSchema: schema
};
} else if (payload.generationConfig) {
delete payload.generationConfig;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 429) {
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
attempt++;
continue;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const candidate = result.candidates?.[0];
if (candidate && candidate.content?.parts?.[0]?.text) {
const text = candidate.content.parts[0].text;
if (isJson) {
try {
return JSON.parse(text);
} catch (e) {
console.error("Failed to parse JSON from LLM response:", text);
throw new Error("Invalid JSON response from LLM.");
}
}
return text; // JSONでない場合はテキストを返す
} else {
throw new Error("No valid content found in LLM response.");
}
} catch (error) {
console.error(`Attempt ${attempt + 1} failed:`, error);
attempt++;
if (attempt >= maxRetries) {
throw new Error("API call failed after multiple retries.");
}
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// --- ミッション生成・ブリーフィング表示 (変更あり) ---
/**
* LLMを使用して新しいミッションを生成し、ブリーフィングを表示
*/
async function generateMission() {
loadingOverlay.classList.remove('hidden');
startMissionBtn.disabled = true;
hangarBtn.disabled = true;
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
あなたは傭兵部隊の司令官「${gameState.commanderName}」です。
あなたはパイロット「${gameState.pilotName}」(通称アルフ)の相棒であり、薄い紫の目とピンクのツインテールの髪をした10代後半の女性です。
現在の難易度レベルに基づいて、簡潔なミッションを提示してください。
ミッションの難易度はレベル${gameState.missionLevel}です。
現在の機体は「${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})」です。
結果を以下のJSONスキーマに従って日本語で、感情を込めて出力してください。
`;
const userQuery = gameState.isFinalMissionAvailable
? `最終目標「プロジェクト・ロンギヌス」に向けた、極秘で危険度の高い重要なミッションを提案せよ。`
: `現在の機体「${currentAircraftData.display}」で実行可能な、レベル${gameState.missionLevel}のランダムなミッション(偵察、迎撃、護衛、対地攻撃など)を提案せよ。`;
const missionSchema = {
type: "OBJECT",
properties: {
missionTitle: { "type": "STRING", "description": "ミッションの簡潔なタイトル" },
briefing: { "type": "STRING", "description": "司令官(ミリィ)からの詳細なブリーフィング" },
baseRewardMoney: { "type": "INTEGER", "description": "成功時の基本報酬額(難易度レベルに応じて、5000~50000程度)" },
baseRewardXP: { "type": "INTEGER", "description": "成功時の基本獲得経験値(難易度レベルに応じて、50~500程度)" },
difficultyRating: { "type": "STRING", "description": "ミッションの難易度(例: E, D, C, B, A, S)" }
},
required: ["missionTitle", "briefing", "baseRewardMoney", "baseRewardXP", "difficultyRating"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
const missionData = await callGeminiApi(payload, 5, true, missionSchema);
gameState.currentMission = missionData;
gameState.chatHistory = []; // チャット履歴をリセット
// UIにブリーフィングを表示
briefingTitle.textContent = `ミッション名: ${missionData.missionTitle} (難易度: ${missionData.difficultyRating})`;
briefingContent.innerHTML = `
<p class="text-sm text-gray-400">レベル: ${gameState.missionLevel} / 機体: ${currentAircraftData.display}</p>
<p class="text-sm text-gray-400 mb-2">基本報酬: ${missionData.baseRewardMoney.toLocaleString()}$ / 基本XP: ${missionData.baseRewardXP} XP</p>
<p class="mt-2 text-lg whitespace-pre-wrap"><span class="font-bold text-green-300">${gameState.commanderName}:</span>${missionData.briefing}</p>
`;
briefingArea.classList.remove('hidden');
// チャットの初期メッセージを追加
const initialMessage = `${gameState.pilotName}、聞いているわね?これが今回の任務の全てよ。何か疑問点や確認したいことはある?遠慮なく聞いて頂戴!`;
gameState.chatHistory.push({ role: "commander", text: initialMessage });
renderChatHistory();
// チャットと開始ボタンを有効化
confirmMissionBtn.disabled = false;
chatInput.disabled = false;
chatSendBtn.disabled = false;
startMissionBtn.disabled = true; // 新しいブリーフィングの取得は会話が終わるまで無効
chatInput.focus();
appendLog(`[システム]:新しいミッション「${missionData.missionTitle}」のブリーフィングを受信しました。司令官とチャットしてください。`, 'text-yellow-400');
} catch (error) {
console.error("Mission generation failed:", error);
showAlert("エラー", "ブリーフィング生成に失敗しました。ネットワーク接続を確認するか、しばらくしてから再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
document.querySelector('#loading-overlay p').textContent = '通信中... ブリーフィングを受信しています';
hangarBtn.disabled = false;
}
}
// --- チャット処理 (新規) ---
async function handleChat() {
const userText = chatInput.value.trim();
if (!userText || !gameState.currentMission) return;
// 1. ユーザーメッセージを履歴に追加・表示
gameState.chatHistory.push({ role: "pilot", text: userText });
appendChatMessage("pilot", userText);
chatInput.value = ''; // 入力欄をクリア
chatInput.disabled = true;
chatSendBtn.disabled = true;
// 2. LLMに司令官(ミリィ)の応答を生成させる
const chatSystemPrompt = `
あなたは傭兵部隊の司令官「${gameState.commanderName}」(ミリィ)です。
あなたはパイロット「${gameState.pilotName}」(アルフ)の相棒であり、薄い紫の目とピンクのツインテールの髪をした10代後半の女性です。
性格は情熱的で、アルフの成功を心から願っています。
現在のミッションは「${gameState.currentMission.missionTitle}」です。
アルフからの質問や意見に対し、自信を持って、そして優しく答え、作戦の成功に向けて導いてください。
あなたの応答は、アルフのモチベーションを高めるよう努めてください。
以下の過去の会話履歴に基づき、簡潔に日本語で応答してください。
`;
// LLMのContents形式に変換
const contents = gameState.chatHistory.map(msg => ({
role: msg.role === 'pilot' ? 'user' : 'model',
parts: [{ text: msg.text }]
}));
const payload = {
contents: contents,
systemInstruction: { parts: [{ text: chatSystemPrompt }] },
};
try {
const commanderResponse = await callGeminiApi(payload, 5, false);
// 3. 司令官の応答を履歴に追加・表示
gameState.chatHistory.push({ role: "commander", text: commanderResponse });
appendChatMessage("commander", commanderResponse);
} catch (error) {
console.error("Chat generation failed:", error);
const fallbackMessage = "ごめんなさい、通信が途切れたわ。もう一度言ってくれる?";
gameState.chatHistory.push({ role: "commander", text: fallbackMessage });
appendChatMessage("commander", fallbackMessage);
showAlert("通信エラー", "司令官との通信が一時的に切断されました。");
} finally {
chatInput.disabled = false;
chatSendBtn.disabled = false;
chatInput.focus();
}
}
// --- ミッション実行 (変更あり) ---
/**
* LLMを使用してミッション結果を判定し、状態を更新
*/
async function executeMission() {
if (!gameState.currentMission) {
showAlert("エラー", "実行するミッションブリーフィングがありません。");
return;
}
loadingOverlay.classList.remove('hidden');
document.querySelector('#loading-overlay p').textContent = '作戦実行中... 結果を分析しています';
confirmMissionBtn.disabled = true;
startMissionBtn.disabled = true;
chatInput.disabled = true;
chatSendBtn.disabled = true;
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const missionData = gameState.currentMission;
// チャット履歴を一つの文字列にまとめる
const chatSummary = gameState.chatHistory.map(msg => `${msg.role === 'pilot' ? gameState.pilotName : gameState.commanderName}: ${msg.text}`).join('\n');
const systemPrompt = `
あなたは空戦の専門家であり、この作戦の評価官です。
パイロット「${gameState.pilotName}」のミッション結果を、機体の性能、ミッション難易度、そして**司令官との事前のチャット内容**を総合的に考慮して判定してください。
チャット履歴は、パイロットの自信や準備状況を示します。積極的で建設的なチャットは成功率を少し上げ、不安や無関心なチャットは成功率を少し下げます。
結果を物語風に記述し、報酬を計算して、以下のJSONスキーマに従って出力してください。
結果は、失敗、成功(標準)、大成功のいずれかです。
`;
const userQuery = `
ミッション: ${missionData.missionTitle} (難易度: ${missionData.difficultyRating} / レベル: ${gameState.missionLevel})
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
事前の司令官(ミリィ)とのチャット履歴(作戦への理解度や自信の度合いを評価に使用せよ):
---
${chatSummary}
---
この条件で、作戦の結果(成否、物語、獲得資金、獲得XP)を判定してください。
`;
const resultSchema = {
type: "OBJECT",
properties: {
success: { "type": "BOOLEAN", "description": "ミッションの成否" },
narration: { "type": "STRING", "description": "戦闘結果の物語風な描写" },
moneyEarned: { "type": "INTEGER", "description": "獲得した資金(失敗時は0またはマイナス)" },
xpEarned: { "type": "INTEGER", "description": "獲得した経験値(失敗時は少ない)" }
},
required: ["success", "narration", "moneyEarned", "xpEarned"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
const resultData = await callGeminiApi(payload, 5, true, resultSchema);
gameState.money += resultData.moneyEarned;
gameState.xp += resultData.xpEarned;
gameState.missionCount += 1;
// 難易度の上昇 (ミッション5回毎にレベルアップ)
if (gameState.missionCount > 0 && gameState.missionCount % 5 === 0) {
gameState.missionLevel += 1;
appendLog(`難易度レベルが上昇しました!現在のレベル: ${gameState.missionLevel}`, 'text-yellow-500 font-bold');
}
// ログに結果を反映
appendLog(`\n--- ミッション結果: ${missionData.missionTitle} ---`, 'text-white font-bold');
appendLog(`[評価官]:${resultData.narration}`, resultData.success ? 'text-blue-300' : 'text-red-400');
appendLog(`[結果]:${resultData.success ? '成功' : '失敗'}`, resultData.success ? 'text-green-500' : 'text-red-500');
appendLog(`[獲得]:資金 ${resultData.moneyEarned.toLocaleString()}$ / 経験値 ${resultData.xpEarned} XP`, 'text-cyan-400');
appendLog(`[${gameState.commanderName}]:お疲れ様、アルフ。格納庫に戻って次に向けて準備するのよ!`, 'text-pink-400');
// ミッション状態をリセット
gameState.currentMission = null;
gameState.chatHistory = [];
briefingArea.classList.add('hidden');
startMissionBtn.disabled = false;
await saveGameState(); // 状態を保存
updateUI(); // UIを更新
} catch (error) {
console.error("Mission execution failed:", error);
showAlert("エラー", "ミッション結果の判定に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
document.querySelector('#loading-overlay p').textContent = '通信中... ブリーフィングを受信しています';
hangarBtn.disabled = false;
}
}
// --- 最終ミッション実行 (変更なし) ---
async function executeFinalMission() {
if (!gameState.isFinalMissionAvailable) {
showAlert("通知", "最終目標の準備が整っていません。さらに多くのミッションをこなし、経験値を積んでください。");
return;
}
loadingOverlay.classList.remove('hidden');
document.querySelector('#loading-overlay p').textContent = '最終目標作戦実行中...';
startMissionBtn.disabled = true;
hangarBtn.disabled = true;
finalMissionBtn.disabled = true;
confirmMissionBtn.disabled = true;
briefingArea.classList.add('hidden'); // 最終ミッションはチャット無しで即実行
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
あなたは空戦の専門家であり、最終目標「プロジェクト・ロンギヌス」作戦の評価官です。
パイロット「${gameState.pilotName}」の作戦結果を、機体「${currentAircraftData.display}」の性能と、これまでの彼の功績を考慮して判定してください。
結果は、大勝利、勝利、または作戦失敗のいずれかです。
結果を物語風に記述し、ゲームの最終的な結末として提示してください。
結果はJSONスキーマに従って出力してください。
`;
const userQuery = `
最終目標: プロジェクト・ロンギヌス
パイロット: ${gameState.pilotName}
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
これまでの総経験値: ${gameState.xp.toLocaleString()} XP
この条件で、作戦の結果(成否、物語)を判定してください。最終ミッションのため、報酬の計算は不要です。
`;
const finalResultSchema = {
type: "OBJECT",
properties: {
success: { "type": "BOOLEAN", "description": "最終目標の成否" },
ending: { "type": "STRING", "description": "ゲームの最終的な結末となる物語" }
},
required: ["success", "ending"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
const resultData = await callGeminiApi(payload, 5, true, finalResultSchema);
appendLog(`\n--- 最終目標「プロジェクト・ロンギヌス」結果 ---`, 'text-red-500 font-extrabold text-lg');
appendLog(`[結末]:${resultData.ending}`, resultData.success ? 'text-yellow-200' : 'text-red-400');
appendLog(`ゲーム終了。${gameState.pilotName}の傭兵としての伝説はここで幕を閉じる...`, 'text-white font-bold');
// 最終ミッション実行後は、ミッションボタンを無効化
startMissionBtn.disabled = true;
finalMissionBtn.disabled = true;
hangarBtn.disabled = true;
gameState.isFinalMissionAvailable = false;
await saveGameState();
} catch (error) {
console.error("Final mission execution failed:", error);
showAlert("エラー", "最終目標の判定に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
}
}
// --- 格納庫 (機体交換) ロジック (変更なし) ---
function openHangar() {
document.getElementById('hangar-money-display').textContent = gameState.money.toLocaleString();
aircraftList.innerHTML = '';
AIRCRAFT_DATA.forEach(aircraft => {
const isCurrent = aircraft.name === gameState.currentAircraft;
const canAfford = gameState.money >= aircraft.cost;
const meetsXP = gameState.xp >= aircraft.requiredXP;
const canPurchase = !isCurrent && canAfford && meetsXP;
const cardHtml = `
<div class="p-4 rounded-lg border ${isCurrent ? 'border-yellow-500 bg-yellow-900/20' : 'border-[#444c56] bg-[#30363d]'}">
<h4 class="text-xl font-bold ${isCurrent ? 'text-yellow-300' : 'text-white'}">${aircraft.display}</h4>
<p class="text-sm text-gray-400 mt-1">性能ランク: <span class="font-bold">${aircraft.performance}</span></p>
<p class="text-sm text-gray-400">必要資金: ${aircraft.cost.toLocaleString()}$ / 必要XP: ${aircraft.requiredXP.toLocaleString()} XP</p>
<div class="mt-3">
${isCurrent
? `<button class="w-full bg-gray-500 text-white font-bold py-2 px-4 rounded-lg cursor-default" disabled>現在搭乗中</button>`
: canPurchase
? `<button class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg purchase-btn" data-aircraft-name="${aircraft.name}">乗り換え</button>`
: `<button class="w-full bg-red-800 text-white font-bold py-2 px-4 rounded-lg cursor-default" disabled>
${!canAfford ? '資金不足' : !meetsXP ? 'XP不足' : '購入不可'}
</button>`
}
</div>
</div>
`;
aircraftList.insertAdjacentHTML('beforeend', cardHtml);
});
aircraftList.querySelectorAll('.purchase-btn').forEach(button => {
button.addEventListener('click', (e) => {
const newAircraftName = e.target.getAttribute('data-aircraft-name');
purchaseAircraft(newAircraftName);
});
});
hangarModal.classList.remove('hidden');
hangarModal.classList.add('flex');
}
async function purchaseAircraft(newAircraftName) {
const newAircraftData = AIRCRAFT_DATA.find(a => a.name === newAircraftName);
if (!newAircraftData) return;
if (gameState.money >= newAircraftData.cost) {
gameState.money -= newAircraftData.cost;
gameState.currentAircraft = newAircraftName;
appendLog(`[システム]:新しい機体「${newAircraftData.display}」に乗り換えました。資金を${newAircraftData.cost.toLocaleString()}$消費。`, 'text-yellow-500');
await saveGameState();
updateUI();
openHangar();
showAlert("乗り換え完了", `「${newAircraftData.display}」への乗り換えが完了しました。新たな機体で任務に挑んでください!`);
} else {
showAlert("資金不足", `${newAircraftData.display}を購入するための資金が不足しています。`);
}
}
// --- イベントリスナー設定と初期化 (変更あり) ---
function setupGame() {
setupFirestoreListener();
// 名前設定モーダル
nameConfigBtn.addEventListener('click', () => {
document.getElementById('input-pilot-name').value = gameState.pilotName;
document.getElementById('input-commander-name').value = gameState.commanderName;
nameModal.classList.remove('hidden');
nameModal.classList.add('flex');
});
closeNameModal.addEventListener('click', () => {
nameModal.classList.remove('flex');
nameModal.classList.add('hidden');
});
saveNameBtn.addEventListener('click', async () => {
const pilotName = document.getElementById('input-pilot-name').value.trim();
const commanderName = document.getElementById('input-commander-name').value.trim();
if (pilotName && commanderName) {
const isNewGame = !(gameState.pilotName && gameState.commanderName);
gameState.pilotName = pilotName;
gameState.commanderName = commanderName;
await saveGameState();
updateUI();
nameModal.classList.remove('flex');
nameModal.classList.add('hidden');
if (isNewGame) {
appendLog(`[${gameState.commanderName}]:ようこそ、${gameState.pilotName}。これが君の愛機、A-4 スカイホークだ。ここから伝説を築き上げろ。`, 'text-green-500');
showAlert("開始", `${gameState.pilotName} パイロットと ${gameState.commanderName} 司令官の名前が設定されました。ブリーフィングを表示してミッションを開始しましょう!`);
} else {
showAlert("更新完了", "名前が更新されました。");
}
} else {
showAlert("入力エラー", "パイロット名と司令官名の両方を入力してください。");
}
});
// ミッションフローのボタンイベント
startMissionBtn.addEventListener('click', generateMission); // ブリーフィング表示
confirmMissionBtn.addEventListener('click', executeMission); // ミッション開始
// チャット関連イベント
chatSendBtn.addEventListener('click', handleChat);
chatInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleChat();
e.preventDefault();
}
});
// 格納庫ボタン
hangarBtn.addEventListener('click', openHangar);
closeHangarModal.addEventListener('click', () => {
hangarModal.classList.remove('flex');
hangarModal.classList.add('hidden');
});
// 最終目標ボタン
finalMissionBtn.addEventListener('click', executeFinalMission);
// メッセージモーダルを閉じる
closeMessageModal.addEventListener('click', () => {
messageModal.classList.remove('flex');
messageModal.classList.add('hidden');
});
}
</script>
</body>
</html>
V0.81 シナリオモード (しかし、シナリオが派生しすぎ)
ちょっと方向性に迷走中
なんとAIがシナリオをジェネレートするのでリアルだが…
もうロールプレイに近くなってきたので再調整でゲーム性を構成しないと物語を読む感じになってしまう。
でも、これはこれで嫌いではないが…。

ブリーフィング画面のBGMが脳内再生される(笑)

もしかしたらすごい別のものに化けるかも
(この為、タイトルが大空の傭兵になってます。(識別用))
もうわけわからん感じで、Geminiとまた喧嘩中(笑) 既に7本位完成途中でバランス書き換えて内部崩壊させられてる、なんとも引き算は無理っぽい。引き算(今あるのバランスを削るような修正やると壊れる)苦手っぽいのは相変わらず
今回はエロ設定は無いのに何で内部ファイルを書き換えて壊すかな!
司令官との恋愛シナリオが気に入らないらしい…うーん、漫画家と編集の方針巡っての対立じゃないんだからさあ…(笑)
後半に機体をシナリオに合わせて追加したら想定外らしくてやたら壊れる。
次回は予め全部の機体を先に伝えとくしかないか…。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大空の傭兵シミュレーター</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Interフォントをロード -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<!-- Firebase SDKのインポート (コアサービスとAuthのみ) -->
<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";
// 修正: ユーティリティ関数 (doc, setDoc, getDoc, onSnapshot) は下のブロックに移動
import { getFirestore, setLogLevel } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// Firestoreのログレベルを設定 (デバッグ用)
setLogLevel('Debug');
// グローバル変数としてFirestore, Authインスタンスを公開
window.firebase = {
db: null,
auth: null,
userId: null,
isAuthReady: false
};
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = JSON.parse(typeof __firebase_config !== 'undefined' ? __firebase_config : '{}');
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
if (Object.keys(firebaseConfig).length > 0) {
const app = initializeApp(firebaseConfig);
window.firebase.db = getFirestore(app);
window.firebase.auth = getAuth(app);
onAuthStateChanged(window.firebase.auth, async (user) => {
if (user) {
window.firebase.userId = user.uid;
} else {
// 認証されていない場合、カスタムトークンがあればサインインを試みる
if (initialAuthToken) {
try {
await signInWithCustomToken(window.firebase.auth, initialAuthToken);
} catch (error) {
console.error("Custom token sign-in failed:", error);
// 失敗しても匿名サインインを試みる
await signInAnonymously(window.firebase.auth);
}
} else {
// トークンがなければ匿名サインイン
await signInAnonymously(window.firebase.auth);
}
// 認証後のユーザーIDを取得。匿名サインインの場合もuidが設定される。
window.firebase.userId = window.firebase.auth.currentUser?.uid || crypto.randomUUID();
}
window.firebase.isAuthReady = true;
document.dispatchEvent(new CustomEvent('firebaseAuthReady'));
});
} else {
console.error("Firebase configuration is missing.");
}
</script>
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #0d1117;
color: #c9d1d9;
}
.console-log {
height: 300px;
overflow-y: auto;
background-color: #161b22;
border-top: 2px solid #30363d;
}
.scroll-container::-webkit-scrollbar {
width: 8px;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: #30363d;
border-radius: 4px;
}
.scroll-container::-webkit-scrollbar-track {
background: #161b22;
}
</style>
</head>
<body class="p-4 sm:p-6 lg:p-8">
<div id="loading-overlay" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 transition-opacity duration-300 hidden">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto"></div>
<p class="mt-4 text-lg font-semibold text-green-400">通信中... ブリーフィングを受信しています</p>
</div>
</div>
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold mb-6 text-green-400">大空の傭兵 - The Sky Mercenary</h1>
<p id="user-id-display" class="text-sm text-gray-500 mb-4">ユーザーID: Loading...</p>
<!-- ステータス表示カード -->
<div class="bg-[#161b22] p-6 rounded-xl shadow-lg mb-6 border border-[#30363d] grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-400">パイロット名 (<span id="pilot-display">未設定</span>)</p>
<h2 id="pilot-name-display" class="text-2xl font-semibold text-white">---</h2>
<p class="text-sm text-gray-400 mt-2">司令官名 (<span id="commander-display">未設定</span>)</p>
<h2 id="commander-name-display" class="text-xl font-semibold text-gray-300">---</h2>
</div>
<div>
<p class="text-sm text-gray-400">現在の機体</p>
<h2 id="aircraft-display" class="text-3xl font-bold text-yellow-300">A-4 スカイホーク</h2>
<div class="mt-2 text-lg">
<p>資金: <span id="money-display" class="font-bold text-green-500">0</span> $</p>
<p>経験値: <span id="xp-display" class="font-bold text-blue-400">0</span> XP</p>
</div>
</div>
</div>
<!-- アクションと設定セクション -->
<div class="mb-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
<button id="start-mission-btn" class="col-span-1 sm:col-span-3 bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
<i class="fas fa-fighter-jet"></i> 次のミッションを開始
</button>
<button id="name-config-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200">
名前を設定/変更
</button>
<button id="hangar-btn" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled>
格納庫 (機体交換)
</button>
<button id="final-mission-btn" class="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50 col-span-1" disabled>
最終目標へ
</button>
</div>
<!-- ミッション/ログ表示エリア -->
<div class="bg-[#161b22] p-6 rounded-xl shadow-lg border border-[#30363d]">
<h2 class="text-xl font-semibold mb-3 text-white">司令部ログ</h2>
<div id="log-display" class="console-log scroll-container p-3 text-sm text-gray-300 whitespace-pre-wrap">
> ログがここに表示されます。まずは「名前を設定/変更」から開始してください。
</div>
</div>
<!-- モーダルウィンドウ (名前設定) -->
<div id="name-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-md border border-[#30363d]">
<h3 class="text-2xl font-bold mb-4 text-green-400">名前の設定</h3>
<div class="mb-4">
<label for="input-pilot-name" class="block text-sm font-medium mb-1">パイロット名 (主人公)</label>
<input type="text" id="input-pilot-name" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white" placeholder="例: アルフレッド">
</div>
<div class="mb-6">
<label for="input-commander-name" class="block text-sm font-medium mb-1">司令官名</label>
<input type="text" id="input-commander-name" class="w-full p-2 rounded bg-[#30363d] border border-[#444c56] text-white" placeholder="例: ミリィ">
</div>
<div class="flex justify-end space-x-3">
<button id="save-name-btn" class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg">
保存して開始
</button>
<button id="close-name-modal" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg">
キャンセル
</button>
</div>
</div>
</div>
<!-- モーダルウィンドウ (格納庫/機体交換) -->
<div id="hangar-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-lg border border-[#30363d]">
<h3 class="text-2xl font-bold mb-4 text-yellow-400">格納庫 (機体交換)</h3>
<p class="mb-4 text-sm text-gray-400">現在の資金: <span id="hangar-money-display" class="font-bold text-green-500">0</span> $</p>
<div id="aircraft-list" class="space-y-4">
<!-- 機体カードがここに挿入される -->
</div>
<div class="flex justify-end mt-6">
<button id="close-hangar-modal" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg">
閉じる
</button>
</div>
</div>
</div>
<!-- モーダルウィンドウ (メッセージ/アラート) -->
<div id="message-modal" class="fixed inset-0 bg-gray-900 bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-[#1e2329] p-8 rounded-xl shadow-2xl w-full max-w-sm border border-[#30363d]">
<h3 id="message-title" class="text-2xl font-bold mb-4 text-white">通知</h3>
<p id="message-content" class="mb-6 text-gray-300">メッセージ内容</p>
<div class="flex justify-end">
<button id="close-message-modal" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg">
OK
</button>
</div>
</div>
</div>
</div>
<script type="module">
// 修正: Firestoreユーティリティ関数をここでインポートし、スコープ内で利用可能にする
import { doc, setDoc, getDoc, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// Firebase初期化を待機
document.addEventListener('firebaseAuthReady', setupGame);
// APIキーはサーバー側で提供されるため、ここでは空文字列を設定
const API_KEY = "";
const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=";
// --- ゲーム状態と機体データ ---
let gameState = {
pilotName: '',
commanderName: '',
currentAircraft: 'A-4 Skyhawk',
money: 0,
xp: 0,
missionLevel: 1,
missionCount: 0,
isFinalMissionAvailable: false,
};
const AIRCRAFT_DATA = [
{ name: 'A-4 Skyhawk', display: 'A-4 スカイホーク', cost: 0, requiredXP: 0, performance: 'C' },
{ name: 'F-4 Phantom II', display: 'F-4 ファントム II', cost: 50000, requiredXP: 500, performance: 'B' },
{ name: 'F-15 Eagle', display: 'F-15 イーグル', cost: 150000, requiredXP: 1500, performance: 'A' },
{ name: 'Final Machine', display: '??? (最終目標)', cost: 500000, requiredXP: 3000, performance: 'S' }
];
// --- DOM要素 ---
const logDisplay = document.getElementById('log-display');
const startMissionBtn = document.getElementById('start-mission-btn');
const nameConfigBtn = document.getElementById('name-config-btn');
const hangarBtn = document.getElementById('hangar-btn');
const finalMissionBtn = document.getElementById('final-mission-btn');
const loadingOverlay = document.getElementById('loading-overlay');
// モーダル要素
const nameModal = document.getElementById('name-modal');
const hangarModal = document.getElementById('hangar-modal');
const messageModal = document.getElementById('message-modal');
const closeNameModal = document.getElementById('close-name-modal');
const saveNameBtn = document.getElementById('save-name-btn');
const closeHangarModal = document.getElementById('close-hangar-modal');
const closeMessageModal = document.getElementById('close-message-modal');
const aircraftList = document.getElementById('aircraft-list');
// --- Firestore関連関数 ---
/**
* Firestoreのドキュメント参照を取得
* @returns {object} docRef
*/
function getDocRef() {
// doc関数は、このモジュールの先頭でインポートされたため、利用可能
if (!window.firebase.db || !window.firebase.userId) {
console.error("Firebase/UserId not initialized.");
return null;
}
const userId = window.firebase.userId;
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
// プライベートデータパス: /artifacts/{appId}/users/{userId}/merc_game/state
return doc(window.firebase.db, 'artifacts', appId, 'users', userId, 'merc_game', 'state');
}
/**
* ゲーム状態をFirestoreに保存
*/
async function saveGameState() {
const docRef = getDocRef();
if (docRef) {
try {
// setDoc関数は、このモジュールの先頭でインポートされたため、利用可能
await setDoc(docRef, gameState);
console.log("Game state saved successfully.");
} catch (error) {
console.error("Error saving game state:", error);
}
}
}
/**
* Firestoreからゲーム状態をロードし、リアルタイムリスナーを設定
*/
function setupFirestoreListener() {
const docRef = getDocRef();
if (docRef) {
// onSnapshot関数は、このモジュールの先頭でインポートされたため、利用可能
onSnapshot(docRef, (doc) => {
if (doc.exists()) {
const loadedState = doc.data();
gameState = { ...gameState, ...loadedState };
updateUI();
console.log("Game state loaded/updated from Firestore.");
} else {
// 初回ロード時、またはデータがない場合は初期化
console.log("No saved data found. Starting new game.");
saveGameState();
}
// 初期ロード後、ボタンを有効化
startMissionBtn.disabled = !(gameState.pilotName && gameState.commanderName);
hangarBtn.disabled = !(gameState.pilotName && gameState.commanderName);
finalMissionBtn.disabled = !gameState.isFinalMissionAvailable;
}, (error) => {
console.error("Error listening to game state:", error);
});
}
}
// --- UI更新関数 ---
/**
* UIの全要素を現在のgameStateに基づいて更新
*/
function updateUI() {
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft) || AIRCRAFT_DATA[0];
document.getElementById('user-id-display').textContent = `ユーザーID: ${window.firebase.userId || '未認証'}`;
document.getElementById('pilot-display').textContent = gameState.pilotName ? `パイロット: ${gameState.pilotName}` : '未設定';
document.getElementById('commander-display').textContent = gameState.commanderName ? `司令官: ${gameState.commanderName}` : '未設定';
document.getElementById('pilot-name-display').textContent = gameState.pilotName || '---';
document.getElementById('commander-name-display').textContent = gameState.commanderName || '---';
document.getElementById('aircraft-display').textContent = currentAircraftData.display;
document.getElementById('money-display').textContent = gameState.money.toLocaleString();
document.getElementById('xp-display').textContent = gameState.xp.toLocaleString();
startMissionBtn.disabled = !(gameState.pilotName && gameState.commanderName);
hangarBtn.disabled = !(gameState.pilotName && gameState.commanderName);
finalMissionBtn.disabled = !gameState.isFinalMissionAvailable;
if (gameState.xp >= 2500 && gameState.money >= 300000 && !gameState.isFinalMissionAvailable) {
gameState.isFinalMissionAvailable = true;
finalMissionBtn.disabled = false;
appendLog(`[${gameState.commanderName}]:ついに時が来たぞ、${gameState.pilotName}。最終目標「プロジェクト・ロンギヌス」の実行許可が下りた。`, 'text-purple-400');
saveGameState();
}
}
/**
* ログに新しいメッセージを追加
* @param {string} message - 追加するメッセージ
* @param {string} colorClass - メッセージの色 (Tailwindクラス)
*/
function appendLog(message, colorClass = 'text-gray-300') {
const timestamp = new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
const logEntry = document.createElement('p');
logEntry.className = `mt-1 ${colorClass}`;
logEntry.textContent = `[${timestamp}] ${message}`;
logDisplay.appendChild(logEntry);
logDisplay.scrollTop = logDisplay.scrollHeight; // スクロールを最下部に
}
/**
* カスタムアラートを表示
* @param {string} title - モーダルのタイトル
* @param {string} content - モーダルの内容
*/
function showAlert(title, content) {
document.getElementById('message-title').textContent = title;
document.getElementById('message-content').textContent = content;
messageModal.classList.remove('hidden');
messageModal.classList.add('flex');
}
// --- LLM API関連関数 ---
/**
* 指数バックオフ付きのLLM API呼び出し
* @param {object} payload - APIリクエストのペイロード
* @param {number} maxRetries - 最大リトライ回数
* @returns {Promise<object>} APIレスポンスのJSONオブジェクト
*/
async function callGeminiApi(payload, maxRetries = 5) {
const url = API_URL + API_KEY;
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 429) {
// Rate Limit: 指数バックオフ
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
attempt++;
continue;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.candidates && result.candidates.length > 0 && result.candidates[0].content && result.candidates[0].content.parts && result.candidates[0].content.parts.length > 0) {
const jsonText = result.candidates[0].content.parts[0].text;
try {
return JSON.parse(jsonText);
} catch (e) {
console.error("Failed to parse JSON from LLM response:", jsonText);
throw new Error("Invalid JSON response from LLM.");
}
} else {
throw new Error("No valid content found in LLM response.");
}
} catch (error) {
console.error(`Attempt ${attempt + 1} failed:`, error);
attempt++;
if (attempt >= maxRetries) {
throw new Error("API call failed after multiple retries.");
}
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
/**
* LLMを使用して新しいミッションを生成
*/
async function generateMission() {
loadingOverlay.classList.remove('hidden');
startMissionBtn.disabled = true;
hangarBtn.disabled = true;
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
あなたは傭兵部隊の司令官「${gameState.commanderName}」です。
現在の難易度レベルに基づいて、パイロット「${gameState.pilotName}」に簡潔なミッションを提示してください。
ミッションの難易度はレベル${gameState.missionLevel}です。
現在の機体は「${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})」です。
最終目標が有効化されている場合、それに向けた重要な任務を提案してください。
結果を以下のJSONスキーマに従って日本語で出力してください。
`;
const userQuery = gameState.isFinalMissionAvailable
? `最終目標「プロジェクト・ロンギヌス」に向けた、極秘で危険度の高い重要なミッションを提案せよ。`
: `現在の機体「${currentAircraftData.display}」で実行可能な、レベル${gameState.missionLevel}のランダムなミッション(偵察、迎撃、護衛、対地攻撃など)を提案せよ。`;
const missionSchema = {
type: "OBJECT",
properties: {
missionTitle: { "type": "STRING", "description": "ミッションの簡潔なタイトル" },
briefing: { "type": "STRING", "description": "司令官からの詳細なブリーフィング" },
baseRewardMoney: { "type": "INTEGER", "description": "成功時の基本報酬額(難易度レベルに応じて、5000~50000程度)" },
baseRewardXP: { "type": "INTEGER", "description": "成功時の基本獲得経験値(難易度レベルに応じて、50~500程度)" },
difficultyRating: { "type": "STRING", "description": "ミッションの難易度(例: E, D, C, B, A, S)" }
},
required: ["missionTitle", "briefing", "baseRewardMoney", "baseRewardXP", "difficultyRating"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
responseSchema: missionSchema
}
};
try {
const missionData = await callGeminiApi(payload);
appendLog(`\n--- ミッションブリーフィング (レベル ${gameState.missionLevel}) ---`, 'text-yellow-400');
appendLog(`[${gameState.commanderName}]:${gameState.pilotName}、聞け。今回の任務は「${missionData.missionTitle}」だ。`, 'text-green-500');
appendLog(`[${gameState.commanderName}]:${missionData.briefing}`, 'text-gray-300');
appendLog(`想定難易度: ${missionData.difficultyRating} / 基本報酬: ${missionData.baseRewardMoney.toLocaleString()}$ / 基本XP: ${missionData.baseRewardXP} XP`, 'text-gray-400');
// ミッション実行へ進む
await executeMission(missionData);
} catch (error) {
console.error("Mission generation failed:", error);
showAlert("エラー", "ミッション生成に失敗しました。ネットワーク接続を確認するか、しばらくしてから再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
startMissionBtn.disabled = false;
hangarBtn.disabled = false;
}
}
/**
* LLMを使用してミッション結果を判定し、状態を更新
* @param {object} missionData - 生成されたミッションデータ
*/
async function executeMission(missionData) {
loadingOverlay.classList.remove('hidden');
document.querySelector('#loading-overlay p').textContent = '戦闘結果を分析中...';
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
あなたは空戦の専門家であり、この作戦の評価官です。
パイロット「${gameState.pilotName}」のミッション結果を、機体の性能とミッション難易度を考慮して判定してください。
結果を物語風に記述し、報酬を計算して、以下のJSONスキーマに従って出力してください。
機体性能と難易度の差により、失敗、成功(標準)、大成功のいずれかになります。
失敗した場合、資金とXPは0またはマイナスになります。
成功した場合、基本報酬を獲得します。
大成功の場合、基本報酬の1.5倍~2倍を獲得します。
`;
const userQuery = `
ミッション: ${missionData.missionTitle}
ブリーフィング: ${missionData.briefing}
パイロット: ${gameState.pilotName}
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
ミッション難易度: ${missionData.difficultyRating} (レベル: ${gameState.missionLevel})
この条件で、作戦の結果(成否、物語、獲得資金、獲得XP)を判定してください。
`;
const resultSchema = {
type: "OBJECT",
properties: {
success: { "type": "BOOLEAN", "description": "ミッションの成否" },
narration: { "type": "STRING", "description": "戦闘結果の物語風な描写" },
moneyEarned: { "type": "INTEGER", "description": "獲得した資金(失敗時は0またはマイナス)" },
xpEarned: { "type": "INTEGER", "description": "獲得した経験値(失敗時は少ない)" }
},
required: ["success", "narration", "moneyEarned", "xpEarned"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
responseSchema: resultSchema
}
};
try {
const resultData = await callGeminiApi(payload);
gameState.money += resultData.moneyEarned;
gameState.xp += resultData.xpEarned;
gameState.missionCount += 1;
// 難易度の上昇 (ミッション5回毎にレベルアップ)
if (gameState.missionCount % 5 === 0) {
gameState.missionLevel += 1;
appendLog(`難易度レベルが上昇しました!現在のレベル: ${gameState.missionLevel}`, 'text-yellow-500 font-bold');
}
// ログに結果を反映
appendLog(`\n--- ミッション結果: ${missionData.missionTitle} ---`, 'text-white font-bold');
appendLog(`[評価官]:${resultData.narration}`, resultData.success ? 'text-blue-300' : 'text-red-400');
appendLog(`[結果]:${resultData.success ? '成功' : '失敗'}`, resultData.success ? 'text-green-500' : 'text-red-500');
appendLog(`[獲得]:資金 ${resultData.moneyEarned.toLocaleString()}$ / 経験値 ${resultData.xpEarned} XP`, 'text-cyan-400');
await saveGameState(); // 状態を保存
updateUI(); // UIを更新
} catch (error) {
console.error("Mission execution failed:", error);
showAlert("エラー", "ミッション結果の判定に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
document.querySelector('#loading-overlay p').textContent = '通信中... ブリーフィングを受信しています';
startMissionBtn.disabled = false;
hangarBtn.disabled = false;
}
}
/**
* 最終ミッションを実行
*/
async function executeFinalMission() {
if (!gameState.isFinalMissionAvailable) {
showAlert("通知", "最終目標の準備が整っていません。さらに多くのミッションをこなし、経験値を積んでください。");
return;
}
loadingOverlay.classList.remove('hidden');
document.querySelector('#loading-overlay p').textContent = '最終目標作戦実行中...';
startMissionBtn.disabled = true;
hangarBtn.disabled = true;
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
const systemPrompt = `
あなたは空戦の専門家であり、最終目標「プロジェクト・ロンギヌス」作戦の評価官です。
パイロット「${gameState.pilotName}」の作戦結果を、機体「${currentAircraftData.display}」の性能と、これまでの彼の功績を考慮して判定してください。
結果は、大勝利、勝利、または作戦失敗のいずれかです。
結果を物語風に記述し、ゲームの最終的な結末として提示してください。
結果はJSONスキーマに従って出力してください。
`;
const userQuery = `
最終目標: プロジェクト・ロンギヌス
パイロット: ${gameState.pilotName}
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
これまでの総経験値: ${gameState.xp.toLocaleString()} XP
この条件で、作戦の結果(成否、物語)を判定してください。最終ミッションのため、報酬の計算は不要です。
`;
const finalResultSchema = {
type: "OBJECT",
properties: {
success: { "type": "BOOLEAN", "description": "最終目標の成否" },
ending: { "type": "STRING", "description": "ゲームの最終的な結末となる物語" }
},
required: ["success", "ending"]
};
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
responseMimeType: "application/json",
responseSchema: finalResultSchema
}
};
try {
const resultData = await callGeminiApi(payload);
appendLog(`\n--- 最終目標「プロジェクト・ロンギヌス」結果 ---`, 'text-red-500 font-extrabold text-lg');
appendLog(`[結末]:${resultData.ending}`, resultData.success ? 'text-yellow-200' : 'text-red-400');
appendLog(`ゲーム終了。${gameState.pilotName}の傭兵としての伝説はここで幕を閉じる...`, 'text-white font-bold');
// 最終ミッション実行後は、ミッションボタンを無効化
startMissionBtn.disabled = true;
finalMissionBtn.disabled = true;
hangarBtn.disabled = true;
gameState.isFinalMissionAvailable = false;
await saveGameState();
} catch (error) {
console.error("Final mission execution failed:", error);
showAlert("エラー", "最終目標の判定に失敗しました。再試行してください。");
} finally {
loadingOverlay.classList.add('hidden');
}
}
// --- 格納庫 (機体交換) ロジック ---
/**
* 格納庫のUIを生成し、表示
*/
function openHangar() {
document.getElementById('hangar-money-display').textContent = gameState.money.toLocaleString();
aircraftList.innerHTML = '';
const currentAircraftData = AIRCRAFT_DATA.find(a => a.name === gameState.currentAircraft);
AIRCRAFT_DATA.forEach(aircraft => {
const isCurrent = aircraft.name === gameState.currentAircraft;
const canAfford = gameState.money >= aircraft.cost;
const meetsXP = gameState.xp >= aircraft.requiredXP;
const canPurchase = !isCurrent && canAfford && meetsXP;
const cardHtml = `
<div class="p-4 rounded-lg border ${isCurrent ? 'border-yellow-500 bg-yellow-900/20' : 'border-[#444c56] bg-[#30363d]'}">
<h4 class="text-xl font-bold ${isCurrent ? 'text-yellow-300' : 'text-white'}">${aircraft.display}</h4>
<p class="text-sm text-gray-400 mt-1">性能ランク: <span class="font-bold">${aircraft.performance}</span></p>
<p class="text-sm text-gray-400">必要資金: ${aircraft.cost.toLocaleString()}$ / 必要XP: ${aircraft.requiredXP.toLocaleString()} XP</p>
<div class="mt-3">
${isCurrent
? `<button class="w-full bg-gray-500 text-white font-bold py-2 px-4 rounded-lg cursor-default" disabled>現在搭乗中</button>`
: canPurchase
? `<button class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg purchase-btn" data-aircraft-name="${aircraft.name}">乗り換え</button>`
: `<button class="w-full bg-red-800 text-white font-bold py-2 px-4 rounded-lg cursor-default" disabled>
${!canAfford ? '資金不足' : !meetsXP ? 'XP不足' : '購入不可'}
</button>`
}
</div>
</div>
`;
aircraftList.insertAdjacentHTML('beforeend', cardHtml);
});
// 乗り換えボタンにイベントリスナーを設定
aircraftList.querySelectorAll('.purchase-btn').forEach(button => {
button.addEventListener('click', (e) => {
const newAircraftName = e.target.getAttribute('data-aircraft-name');
purchaseAircraft(newAircraftName);
});
});
hangarModal.classList.remove('hidden');
hangarModal.classList.add('flex');
}
/**
* 機体の購入と乗り換え処理
* @param {string} newAircraftName - 乗り換える機体名
*/
async function purchaseAircraft(newAircraftName) {
const newAircraftData = AIRCRAFT_DATA.find(a => a.name === newAircraftName);
if (!newAircraftData) return;
if (gameState.money >= newAircraftData.cost) {
gameState.money -= newAircraftData.cost;
gameState.currentAircraft = newAircraftName;
appendLog(`[システム]:新しい機体「${newAircraftData.display}」に乗り換えました。資金を${newAircraftData.cost.toLocaleString()}$消費。`, 'text-yellow-500');
await saveGameState();
updateUI();
openHangar(); // 格納庫を再描画
showAlert("乗り換え完了", `「${newAircraftData.display}」への乗り換えが完了しました。新たな機体で任務に挑んでください!`);
} else {
showAlert("資金不足", `${newAircraftData.display}を購入するための資金が不足しています。`);
}
}
// --- イベントリスナー設定と初期化 ---
/**
* ゲームの初期設定
*/
function setupGame() {
// Firebase認証が完了したらFirestoreリスナーを開始
setupFirestoreListener();
// 名前設定モーダル
nameConfigBtn.addEventListener('click', () => {
document.getElementById('input-pilot-name').value = gameState.pilotName;
document.getElementById('input-commander-name').value = gameState.commanderName;
nameModal.classList.remove('hidden');
nameModal.classList.add('flex');
});
closeNameModal.addEventListener('click', () => {
nameModal.classList.remove('flex');
nameModal.classList.add('hidden');
});
saveNameBtn.addEventListener('click', async () => {
const pilotName = document.getElementById('input-pilot-name').value.trim();
const commanderName = document.getElementById('input-commander-name').value.trim();
if (pilotName && commanderName) {
const isNewGame = !(gameState.pilotName && gameState.commanderName);
gameState.pilotName = pilotName;
gameState.commanderName = commanderName;
await saveGameState();
updateUI();
nameModal.classList.remove('flex');
nameModal.classList.add('hidden');
if (isNewGame) {
appendLog(`[${gameState.commanderName}]:ようこそ、${gameState.pilotName}。これが君の愛機、A-4 スカイホークだ。ここから伝説を築き上げろ。`, 'text-green-500');
showAlert("開始", `${gameState.pilotName} パイロットと ${gameState.commanderName} 司令官の名前が設定されました。ミッションを開始しましょう!`);
} else {
showAlert("更新完了", "名前が更新されました。");
}
} else {
showAlert("入力エラー", "パイロット名と司令官名の両方を入力してください。");
}
});
// ミッション開始ボタン
startMissionBtn.addEventListener('click', generateMission);
// 格納庫ボタン
hangarBtn.addEventListener('click', openHangar);
closeHangarModal.addEventListener('click', () => {
hangarModal.classList.remove('flex');
hangarModal.classList.add('hidden');
});
// 最終目標ボタン
finalMissionBtn.addEventListener('click', executeFinalMission);
// メッセージモーダルを閉じる
closeMessageModal.addEventListener('click', () => {
messageModal.classList.remove('flex');
messageModal.classList.add('hidden');
});
}
</script>
</body>
</html>V0.8
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from 'firebase/auth';
import { getFirestore, doc, setDoc, onSnapshot, updateDoc } from 'firebase/firestore';
import { Plane, DollarSign, Target, Zap, Shield, Heart, Aperture, Loader2, ArrowLeft, Send, ListPlus, Check, Wrench, BarChart, TrendingUp, TrendingDown, Sun } from 'lucide-react';
// --- 1. 定数とデータモデル ---
// **【修正点】IME安定化のため、初期値をスペース一文字に変更**
const DEFAULT_PILOT_NAME = " ";
const DEFAULT_COMMANDER_NAME = " ";
// ゲームの初期設定
const INITIAL_CREDITS = 2500000; // 250万クレジット
const INITIAL_AIRCRAFT_CODE = 'A4';
const MAX_MISSION_DIFFICULTY = 100;
const DEFAULT_APP_ID = 'mercenary-sim-2024';
const XP_PER_LEVEL = 3; // 3 XPで1レベルアップ
// 修理コスト設定
const REPAIR_COST_PER_HP = 5000; // 失ったHP 1ポイントにつき 5,000 Cr
// 機体データ (Priceは購入価格) - A-10とF-15を追加
const AIRCRAFT_DATA = {
'A4': {
name: 'A-4 Skyhawk', type: '攻撃機', era: '旧世代', price: 5000000,
stats: { A2A: 10, A2G: 40, EVADE: 30, HP: 100 },
icon: '✈️', color: 'bg-green-700'
},
'F5': {
name: 'F-5 Tiger II', type: '戦闘機', era: '旧世代', price: 8000000,
stats: { A2A: 45, A2G: 15, EVADE: 50, HP: 90 },
icon: '🚀', color: 'bg-blue-700'
},
'F16': {
name: 'F-16 Fighting Falcon', type: 'マルチロール', era: '現代', price: 15000000,
stats: { A2A: 60, A2G: 30, EVADE: 65, HP: 120 },
icon: '🦅', color: 'bg-yellow-700'
},
'A10': {
name: 'A-10 Thunderbolt II', type: '攻撃機', era: '現代', price: 12000000,
stats: { A2A: 5, A2G: 80, EVADE: 25, HP: 150 }, // A2G特化、高HP
icon: '🐷', color: 'bg-orange-700'
},
'F15': {
name: 'F-15 Eagle', type: '制空戦闘機', era: '現代', price: 20000000,
stats: { A2A: 90, A2G: 10, EVADE: 70, HP: 110 }, // A2A特化、高回避
icon: '🟦', color: 'bg-cyan-700'
},
};
// ミッションの目標タイプ
const TARGET_TYPES = [
{ name: "地上補給拠点破壊", rewardFactor: 1.0, threatFocus: 'GROUND' },
{ name: "敵制空隊迎撃", rewardFactor: 1.5, threatFocus: 'AIR' },
{ name: "SAMサイト破壊", rewardFactor: 1.8, threatFocus: 'GROUND' },
{ name: "要人輸送機護衛", rewardFactor: 1.2, threatFocus: 'AIR' },
];
// 初期データ構造の定義
const createInitialPilotData = (userId) => ({
userId,
// **初期値がスペース一文字になる**
pilotName: DEFAULT_PILOT_NAME,
commanderName: DEFAULT_COMMANDER_NAME,
credits: INITIAL_CREDITS,
hangar: [
{ id: Date.now(), code: INITIAL_AIRCRAFT_CODE, health: AIRCRAFT_DATA[INITIAL_AIRCRAFT_CODE].stats.HP }
],
currentAircraftId: null,
missionHistory: [],
// パイロットステータスの追加 (全てレベル1、XP 0で開始)
pilotStats: {
A2A_ATK: { level: 1, xp: 0 }, // 対空攻撃力 (成功時、AIRミッションで上昇)
A2G_ATK: { level: 1, xp: 0 }, // 対地攻撃力 (成功時、GROUNDミッションで上昇)
A_DEF: { level: 1, xp: 0 }, // 空中防御力/回避 (失敗時、AIR脅威で上昇)
G_DEF: { level: 1, xp: 0 }, // 地上防御力/耐性 (失敗時、GROUND脅威で上昇)
},
});
// Firestore関連のグローバル変数
let dbInstance = null;
let authInstance = null;
// --- 2. ユーティリティ関数 ---
// クレジット表示を整形
const formatCredits = (amount) => new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY', minimumFractionDigits: 0 }).format(amount).replace('¥', 'Cr');
// 乱数生成(0-max, inclusive)
const randomInt = (max) => Math.floor(Math.random() * (max + 1));
// 機体とパイロットのステータスを統合して最終的な戦闘ステータスを計算
const getCombinedStats = (aircraftCode, pilotStats) => {
const baseStats = AIRCRAFT_DATA[aircraftCode]?.stats || { A2A: 0, A2G: 0, EVADE: 0, HP: 0 };
return {
// A2A: 機体のA2A + パイロットのA2A攻撃レベル
A2A: baseStats.A2A + pilotStats.A2A_ATK.level,
// A2G: 機体のA2G + パイロットのA2G攻撃レベル
A2G: baseStats.A2G + pilotStats.A2G_ATK.level,
// EVADE: 機体の回避 + パイロットの空中防御レベル
EVADE: baseStats.EVADE + pilotStats.A_DEF.level,
// HP: 機体のHP (最大HPは機体依存)
HP: baseStats.HP,
// G-DEF_Level: 地上防御レベル (シミュレーションでダメージ軽減に使用)
G_DEF_Level: pilotStats.G_DEF.level,
};
};
// 司令官の嘆きを生成する (ゲームオーバー時)
const getCommanderLament = (pilotLevel, pilotName, commanderName, cause) => {
// 名前のトリミング(スペースを考慮してtrimする)
const trimmedPilotName = pilotName.trim() || '主人公';
const avgLevel = pilotLevel;
let lament = ``;
let causeText = cause === 'AIR'
? '敵制空隊のミサイルによる攻撃が致命傷となった'
: '地上の対空砲火やSAMサイトの集中砲火を浴びて機体を失った';
// 戦友/淡々とした口調 (Lv < 8)
if (avgLevel < 8) {
lament = (
`「...また一人、優秀な傭兵を失ったわね。契約は契約。これがお金で飛ぶということよ、${trimmedPilotName}。\n` +
`...しかし、${causeText}とは。次はもう少し、慎重なブリーフィングが必要だったわね。」`
);
}
// やや愛情の込もった口調 (Lv 8-14)
else if (avgLevel < 15) {
lament = (
`「彼は優秀だったわ。惜しい...本当に惜しいパイロットだった。\n` +
`まさか、${causeText}だなんて...。あなたの名誉は、この基地が永遠に記憶するわ、${trimmedPilotName}。」`
);
}
// 愛情の込もった口調 (Lv 15+)
else {
lament = (
`「...嘘...だ...。${trimmedPilotName}!どうして、どうしてこんなことに...。\n` +
`無線から聞こえる雑音を、私は永遠に忘れないわ。あなたが${causeText}たなんて...。\n` +
`...必ず、あなたの仇を討つわ。私の愛しいエース。」`
);
}
return {
lament,
commanderName
};
};
// --- 3. メインコンポーネント ---
const App = () => {
// 状態管理
const [userId, setUserId] = useState(null);
const [pilotData, setPilotData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// MAIN, HANGAR, MISSION_SELECTION, MISSION_BRIEF, MISSION_RESULT
const [mode, setMode] = useState('MAIN');
// ミッション関連
const [availableMissions, setAvailableMissions] = useState([]); // 選択可能な3つのミッションリスト
const [currentMission, setCurrentMission] = useState(null); // 選択されたミッション
const [missionLog, setMissionLog] = useState([]);
const [isSimulating, setIsSimulating] = useState(false);
// 認証の準備完了フラグ
const [isAuthReady, setIsAuthReady] = useState(false);
// **【修正点】IME対応: 初期値をDEFAULT_PILOT_NAME (' ') に設定**
const [pilotNameTemp, setPilotNameTemp] = useState(DEFAULT_PILOT_NAME);
const [commanderNameTemp, setCommanderNameTemp] = useState(DEFAULT_COMMANDER_NAME);
const [isComposing, setIsComposing] = useState(false); // IME変換中フラグ
const appId = typeof __app_id !== 'undefined' ? __app_id : DEFAULT_APP_ID;
const pilotDocPath = (uid) => `artifacts/${appId}/users/${uid}/pilotData/main`;
// --- 4. Firebase初期化と認証 (省略 - 変更なし) ---
useEffect(() => {
let app;
let authUnsubscribe;
try {
const configString = typeof __firebase_config !== 'undefined' ? __firebase_config : null;
if (!configString) {
console.error("Firebase Config is missing.");
setError("Firebase設定が見つかりません。");
setLoading(false);
return;
}
const firebaseConfig = JSON.parse(configString);
app = initializeApp(firebaseConfig);
dbInstance = getFirestore(app);
authInstance = getAuth(app);
authUnsubscribe = onAuthStateChanged(authInstance, async (user) => {
let currentUserId = user?.uid;
if (!currentUserId) {
try {
// 初期認証トークンでサインイン、なければ匿名認証
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
const result = await signInWithCustomToken(authInstance, __initial_auth_token);
currentUserId = result.user.uid;
} else {
const result = await signInAnonymously(authInstance);
currentUserId = result.user.uid;
}
} catch (e) {
console.error("Authentication failed:", e);
// 認証失敗時も、ログアウト状態で一応アプリを進める
}
}
setUserId(currentUserId);
setIsAuthReady(true);
});
} catch (e) {
console.error("Firebase initialization failed:", e);
setError(`システムエラー: Firebaseの初期化に失敗しました。詳細: ${e.message}`);
setLoading(false);
}
return () => {
if (authUnsubscribe) authUnsubscribe();
};
}, []);
// --- 5. Pilotデータのロードと監視 (Firestore to pilotData) ---
useEffect(() => {
if (!isAuthReady || !userId || !dbInstance) return;
// Pilotデータドキュメントの監視
const pilotRef = doc(dbInstance, pilotDocPath(userId));
const unsubscribe = onSnapshot(pilotRef, (docSnap) => {
if (docSnap.exists()) {
const data = docSnap.data();
// データ構造のマイグレーション
const initialData = createInitialPilotData(userId);
let updatedData = data;
// DBにデータがない場合の初期値設定もスペース一文字にする
if (!data.pilotStats) updatedData = { ...updatedData, pilotStats: initialData.pilotStats };
// pilotNameが空またはnullの場合、デフォルトのスペース一文字を設定
if (!data.pilotName || data.pilotName.trim() === '') updatedData = { ...updatedData, pilotName: DEFAULT_PILOT_NAME };
// commanderNameが空またはnullの場合、デフォルトのスペース一文字を設定
if (!data.commanderName || data.commanderName.trim() === '') updatedData = { ...updatedData, commanderName: DEFAULT_COMMANDER_NAME };
if (JSON.stringify(data) !== JSON.stringify(updatedData)) {
setDoc(pilotRef, updatedData)
.then(() => setPilotData(updatedData))
.catch(e => console.error("Migration failed:", e));
} else {
setPilotData(updatedData);
}
} else {
// データが存在しない場合は初期データを作成して保存
const initialData = createInitialPilotData(userId);
setDoc(pilotRef, initialData)
.then(() => {
setPilotData(initialData);
console.log("Initial Pilot Data Created.");
})
.catch(e => {
console.error("Failed to set initial pilot data:", e);
setError("初期データ設定中にエラーが発生しました。");
});
}
setLoading(false);
}, (e) => {
console.error("Firestore Snapshot Error:", e);
setError(`データ監視中にエラーが発生しました。: ${e.message}`);
setLoading(false);
});
return unsubscribe;
}, [isAuthReady, userId]);
// --- 6. IME対応: pilotData (Firestore) から Temp State への同期 ---
// Firestoreデータが変更されたとき、IME入力中でなければローカルの一時状態を更新する
useEffect(() => {
if (pilotData && !isComposing) {
// DBの値(スペース含む)をローカルに反映
setPilotNameTemp(pilotData.pilotName);
setCommanderNameTemp(pilotData.commanderName);
}
}, [pilotData, isComposing]);
// --- 7. ロジック: 機体の取得と更新 (省略 - 変更なし) ---
const currentAircraft = useMemo(() => {
if (!pilotData || !pilotData.currentAircraftId) return null;
const aircraft = pilotData.hangar.find(a => a.id === pilotData.currentAircraftId);
if (!aircraft) return null;
// パイロットのステータスを統合して最終的な戦闘ステータスを計算
const combinedStats = getCombinedStats(aircraft.code, pilotData.pilotStats);
return {
...aircraft,
data: AIRCRAFT_DATA[aircraft.code],
stats: combinedStats, // 統合されたスタッツを使用
maxHP: combinedStats.HP,
};
}, [pilotData]);
// パイロットの平均レベルを計算
const currentPilotLevel = useMemo(() => {
if (!pilotData) return 1;
const totalLevel = Object.values(pilotData.pilotStats).reduce((sum, stat) => sum + stat.level, 0);
return Math.floor(totalLevel / 4);
}, [pilotData]);
/**
* PilotDataを更新する関数。入力フィールドの競合を防ぐためupdateDocを使用する。
* @param {Object} updates - 更新したいフィールドと値のオブジェクト (例: { pilotName: '新しい名前' })
*/
const updatePilotData = useCallback(async (updates) => {
if (!dbInstance || !userId) return;
const pilotRef = doc(dbInstance, pilotDocPath(userId));
try {
// updateDocを使用して、指定されたフィールドのみをマージ更新
await updateDoc(pilotRef, updates);
} catch (e) {
console.error("Failed to update pilot data:", e);
setError(`データ更新に失敗しました。: ${e.message}`);
}
}, [dbInstance, userId]);
// --- 8. ロジック: ハンガー操作 (省略 - 変更なし) ---
/**
* 機体の修理処理。
*/
const handleRepairAircraft = (aircraftId) => {
const aircraft = pilotData.hangar.find(a => a.id === aircraftId);
if (!aircraft) return;
const maxHP = getCombinedStats(aircraft.code, pilotData.pilotStats).HP;
const missingHP = maxHP - aircraft.health;
if (missingHP === 0) return; // すでに満タン
const cost = missingHP * REPAIR_COST_PER_HP;
if (pilotData.credits < cost) {
console.warn("修理クレジットが不足しています。");
return;
}
const newCredits = pilotData.credits - cost;
const updatedHangar = pilotData.hangar.map(a =>
a.id === aircraftId ? { ...a, health: maxHP } : a
);
updatePilotData({
credits: newCredits,
hangar: updatedHangar,
});
};
const handleBuyAircraft = (aircraftCode) => {
const data = AIRCRAFT_DATA[aircraftCode];
if (pilotData.credits < data.price) {
console.warn("クレジットが不足しています。");
return;
}
const newAircraft = { id: Date.now(), code: aircraftCode, health: data.stats.HP };
const newCredits = pilotData.credits - data.price;
updatePilotData({
credits: newCredits,
hangar: [...pilotData.hangar, newAircraft],
});
};
const handleSellAircraft = (aircraftId) => {
const aircraft = pilotData.hangar.find(a => a.id === aircraftId);
if (!aircraft) return;
const resaleValue = Math.floor(AIRCRAFT_DATA[aircraft.code].price / 2);
const newCredits = pilotData.credits + resaleValue;
let newCurrentAircraftId = pilotData.currentAircraftId;
if (newCurrentAircraftId === aircraftId) {
// 売却機体が現在選択中の機体だった場合、ハンガーに残っている最初の機体を選択し直す
newCurrentAircraftId = pilotData.hangar.filter(a => a.id !== aircraftId)[0]?.id || null;
}
updatePilotData({
credits: newCredits,
hangar: pilotData.hangar.filter(a => a.id !== aircraftId),
currentAircraftId: newCurrentAircraftId,
});
};
const handleSelectAircraft = (aircraftId) => {
updatePilotData({ currentAircraftId: aircraftId });
};
// --- 9. ロジック: 複数のミッション生成 (省略 - 変更なし) ---
const generateAvailableMissions = () => {
if (!pilotData || !currentAircraft) return;
const missions = [];
const baseReward = 500000;
// パイロットスキルを考慮した現在の総合性能
const currentA2A = currentAircraft.stats.A2A;
const currentA2G = currentAircraft.stats.A2G;
// 3つのミッション難易度プロファイル (脅威レベルのオフセット)
const difficultyProfiles = [
{ name: "低リスク/低報酬", airOffset: randomInt(15) - 20, groundOffset: randomInt(15) - 20 },
{ name: "通常リスク/通常報酬", airOffset: randomInt(10) - 5, groundOffset: randomInt(10) - 5 },
{ name: "高リスク/高報酬", airOffset: randomInt(15) + 5, groundOffset: randomInt(15) + 5 },
];
// ミッション生成ロジック
const generateSpecificMission = (profile) => {
const targetType = TARGET_TYPES[randomInt(TARGET_TYPES.length - 1)];
// ベース性能 + 難易度オフセット
let targetA2A = currentA2A + profile.airOffset;
let targetA2G = currentA2G + profile.groundOffset;
// 脅威レベルを制限
targetA2A = Math.min(MAX_MISSION_DIFFICULTY, Math.max(10, targetA2A));
targetA2G = Math.min(MAX_MISSION_DIFFICULTY, Math.max(10, targetA2G));
const allySupport = randomInt(50) + 10; // 10-60
const difficultyScore = Math.floor((targetA2A + targetA2G) / 2);
// 報酬はベース報酬 x 目標の重要度 x 難易度スコア
const reward = Math.floor(baseReward * targetType.rewardFactor * (1 + difficultyScore / 100));
return {
id: Date.now() + Math.random(),
profileName: profile.name,
target: targetType.name,
threatFocus: targetType.threatFocus,
threats: {
air: targetA2A,
ground: targetA2G,
},
reward: reward,
difficultyScore,
};
};
difficultyProfiles.forEach(profile => {
missions.push(generateSpecificMission(profile));
});
missions.sort((a, b) => a.difficultyScore - b.difficultyScore);
setAvailableMissions(missions);
setMode('MISSION_SELECTION');
};
const handleMissionSelect = (mission) => {
setCurrentMission(mission);
setMissionLog([]);
setMode('MISSION_BRIEF');
};
// --- 10. ロジック: ミッションシミュレーションと経験値計算 (省略 - 変更なし) ---
const runMissionSimulation = () => {
if (!currentMission || !currentAircraft || isSimulating) return;
setIsSimulating(true);
let log = [];
let currentHP = currentAircraft.health;
let isMissionSuccess = false;
let isAircraftLost = false;
let causeOfDeath = null; // 撃墜原因を記録
const stats = currentAircraft.stats;
const { air, ground } = currentMission.threats;
let currentPilotStats = { ...pilotData.pilotStats }; // 経験値計算用のコピー
// --- フェーズ 1: 目標達成判定 ---
const primaryStat = currentMission.threatFocus === 'AIR' ? stats.A2A : stats.A2G;
const targetThreshold = currentMission.threatFocus === 'AIR' ? air : ground;
const successChance = Math.min(100, primaryStat + 10 - targetThreshold * 0.5);
if (randomInt(100) < successChance) {
isMissionSuccess = true;
log.push({ type: 'SUCCESS', text: `目標を確保/破壊!ミッション成功への道を開いた。 (成功率 ${successChance.toFixed(0)}%)` });
} else {
isMissionSuccess = false;
log.push({ type: 'FAILURE', text: `目標の確保/破壊に失敗。作戦は部分達成に移行。 (成功率 ${successChance.toFixed(0)}%)` });
}
// --- XP計算 (成功時: 攻撃力) ---
if (isMissionSuccess) {
const targetStat = currentMission.threatFocus === 'AIR' ? 'A2A_ATK' : 'A2G_ATK';
currentPilotStats[targetStat].xp += 1;
// レベルアップ判定
if (currentPilotStats[targetStat].xp >= XP_PER_LEVEL) {
currentPilotStats[targetStat].level += 1;
currentPilotStats[targetStat].xp = 0;
log.push({ type: 'LEVELUP', text: `${targetStat.replace('_ATK', '')}攻撃力がレベル ${currentPilotStats[targetStat].level} に上昇!` });
}
}
// --- フェーズ 2: ダメージ判定(地上脅威) ---
if (currentMission.threats.ground > 0) {
// G-DEFレベルを地上ダメージ軽減に使う
const G_DEF_Level = stats.G_DEF_Level;
const G_DEF_Reduction = G_DEF_Level * 1.5; // 1レベルあたり1.5ポイントのダメージ軽減
const evasionBonus = stats.EVADE * 0.5;
const groundThreatFactor = currentMission.threats.ground * 0.8;
let rawDamage = Math.max(0, groundThreatFactor - evasionBonus / 5);
let totalDamage = Math.floor(rawDamage + randomInt(10));
// G-DEFによるダメージ軽減を適用
totalDamage = Math.max(1, Math.floor(totalDamage - G_DEF_Reduction)); // 最低1ダメージは受ける
currentHP = Math.max(0, currentHP - totalDamage);
log.push({ type: 'DAMAGE', text: `地上からの激しい対空砲火! ${totalDamage} ダメージを受けた。 (残HP ${currentHP}/${currentAircraft.maxHP})` });
// --- 地上脅威による撃墜判定 ---
if (currentHP === 0) {
isAircraftLost = true;
causeOfDeath = 'GROUND';
log.push({ type: 'CRASH', text: `機体は地上脅威により大破、コントロールを失い墜落...。パイロットは緊急脱出に成功!` });
} else {
// --- XP計算 (失敗時: 地上防御力) ---
if (!isMissionSuccess) {
currentPilotStats['G_DEF'].xp += 1;
if (currentPilotStats['G_DEF'].xp >= XP_PER_LEVEL) {
currentPilotStats['G_DEF'].level += 1;
currentPilotStats['G_DEF'].xp = 0;
log.push({ type: 'LEVELUP', text: `G-DEF地上防御力がレベル ${currentPilotStats['G_DEF'].level} に上昇!` });
}
}
}
}
// --- フェーズ 3: ダメージ判定(敵機脅威と撃墜判定) ---
if (currentMission.threats.air > 0 && currentHP > 0) {
const enemyA2A = currentMission.threats.air;
const playerA2A = stats.A2A;
const survivability = stats.HP * 0.2 + currentMission.allySupport * 0.5;
// 敵の攻撃成功率
const enemyHitChance = Math.max(10, enemyA2A - playerA2A * 0.8);
if (randomInt(100) < enemyHitChance) {
// 被弾した場合
const airDamage = randomInt(20) + 15; // 15-35 ダメージ
currentHP = Math.max(0, currentHP - airDamage);
log.push({ type: 'DAMAGE', text: `敵機「MiG-21」のミサイルが直撃! ${airDamage} ダメージ! (残HP ${currentHP}/${currentAircraft.maxHP})` });
// --- XP計算 (失敗時: 空中防御力) ---
if (!isMissionSuccess) {
currentPilotStats['A_DEF'].xp += 1;
if (currentPilotStats['A_DEF'].xp >= XP_PER_LEVEL) {
currentPilotStats['A_DEF'].level += 1;
currentPilotStats['A_DEF'].xp = 0;
log.push({ type: 'LEVELUP', text: `A-DEF空中防御力がレベル ${currentPilotStats['A_DEF'].level} に上昇!` });
}
}
// 撃墜判定 (致命的ダメージ)
const isNearDeath = currentHP < (currentAircraft.maxHP * 0.2);
const crashChance = isNearDeath ? 40 : 10;
if (currentHP === 0 || (randomInt(100) < crashChance && survivability < 50)) {
isAircraftLost = true;
causeOfDeath = 'AIR';
currentHP = 0; // 撃墜によりHPゼロ
log.push({ type: 'CRASH', text: `回避不能!機体はコントロールを失い墜落...。パイロットは緊急脱出に成功!` });
}
} else {
log.push({ type: 'DEFENSE', text: `敵機をドッグファイトで翻弄!攻撃を完全に回避した。` });
}
}
// --- フェーズ 4: 結果確定とデータ更新 (省略 - 変更なし) ---
const finalReward = isMissionSuccess ? currentMission.reward : 0;
setTimeout(() => {
setMissionLog(log);
// データ更新
if (isAircraftLost) {
// 撃墜された場合: 機体除外、クレジット加算、XP更新
const newCredits = pilotData.credits + finalReward;
const newHangar = pilotData.hangar.filter(a => a.id !== currentAircraft.id);
const newCurrentAircraftId = newHangar[0]?.id || null;
updatePilotData({
credits: newCredits,
hangar: newHangar,
currentAircraftId: newCurrentAircraftId,
pilotStats: currentPilotStats, // XP更新
missionHistory: [...pilotData.missionHistory, {
date: Date.now(), success: isMissionSuccess, lost: true, reward: finalReward,
aircraft: currentAircraft.data.name, aircraftMaxHP: currentAircraft.maxHP,
causeOfDeath: causeOfDeath || 'UNKNOWN', // 撃墜原因を追加
}]
}).then(() => {
setIsSimulating(false);
setMode('MISSION_RESULT');
});
} else {
// 生存した場合: HP更新、クレジット加算、XP更新
const newCredits = pilotData.credits + finalReward;
const updatedHangar = pilotData.hangar.map(a =>
a.id === currentAircraft.id ? { ...a, health: currentHP } : a
);
updatePilotData({
credits: newCredits,
hangar: updatedHangar,
pilotStats: currentPilotStats, // XP更新
missionHistory: [...pilotData.missionHistory, {
date: Date.now(), success: isMissionSuccess, lost: false, reward: finalReward,
aircraft: currentAircraft.data.name, aircraftMaxHP: currentAircraft.maxHP,
causeOfDeath: null, // 撃墜原因はなし
}]
}).then(() => {
setIsSimulating(false);
setMode('MISSION_RESULT');
});
}
}, 1500); // 1.5秒待って結果を確定
};
// --- 11. ロジック: 司令官ブリーフィングの生成 (pilotNameをtrim) ---
const getCommanderBriefing = (pilotStats, mission, commanderName) => {
const trimmedPilotName = pilotData.pilotName.trim() || '主人公';
const totalLevel = Object.values(pilotStats).reduce((sum, stat) => sum + stat.level, 0);
const avgLevel = totalLevel / 4;
let tone = 'comrade';
let opening = `${trimmedPilotName}、これがお前の次の仕事だ。この契約をこなせば、お前の口座は潤うぞ。`;
let closing = `...絶対に死ぬんじゃないぞ、相棒。`; // 戦友口調
if (avgLevel >= 15) { // Ace Tier (Lvl 15+)
tone = 'love';
opening = `私の愛しい${trimmedPilotName}。あなたのために選んだ、最高の舞台を用意したわ。さあ、出番よ。`;
closing = `...怪我しないで。私の、大切な人。`; // 愛情口調
} else if (avgLevel >= 8) { // Veteran Tier (Lvl 8-14)
tone = 'veteran';
opening = `よく来たわね、${trimmedPilotName}。あなたの腕前なら、このミッションも楽勝でしょう?期待しているわ。`;
closing = `無事に帰投しなさい。貴方がいないと、このハンガーが寂しいもの。`; // やや愛情の込もった口調
}
// ミッション内容に合わせた調整
const targetThreat = mission.threatFocus === 'AIR' ? '空中' : '地上';
const primaryStatName = mission.threatFocus === 'AIR' ? 'A2A攻撃力' : 'A2G攻撃力';
const primaryStatLevel = mission.threatFocus === 'AIR' ? pilotStats.A2A_ATK.level : pilotStats.G2A_ATK.level;
const missionDetail = `目標は「${mission.target}」。特に${targetThreat}脅威が高く、難易度スコアは${mission.difficultyScore}だ。あなたの現在の${primaryStatName}はレベル${primaryStatLevel}よ。油断はしないで。`;
return {
opening,
detail: missionDetail,
closing,
commanderName: commanderName || "司令官ミリィ", // 設定名を反映
tone
};
};
// --- 12. UIコンポーネント ---
const StatPill = ({ Icon, label, value, color }) => (
<div className={`flex items-center text-sm font-semibold p-2 rounded-full ${color}`}>
<Icon size={16} className="mr-1" />
<span>{label}: {value}</span>
</div>
);
const PilotStatPill = ({ label, stat, color, icon }) => (
<div className="bg-slate-700 p-3 rounded-lg flex items-center justify-between">
<div className="flex items-center">
{icon}
<span className={`ml-2 font-semibold ${color}`}>{label}</span>
</div>
<div className="text-right">
<p className="text-lg font-bold text-white">Lv. {stat.level}</p>
<p className="text-xs text-slate-400">XP: {stat.xp}/{XP_PER_LEVEL}</p>
</div>
</div>
);
const AircraftCard = ({ aircraft, isSelected, onClick, onSell, onRepair, currentCredits }) => {
const data = aircraft.data;
const stats = aircraft.stats;
const resaleValue = Math.floor(data.price / 2);
const healthPercent = Math.min(100, Math.max(0, (aircraft.health / stats.HP) * 100));
const isDamaged = aircraft.health < stats.HP;
const missingHP = stats.HP - aircraft.health;
const repairCost = missingHP * REPAIR_COST_PER_HP;
const canAffordRepair = currentCredits >= repairCost;
return (
<div
className={`bg-slate-700 p-4 rounded-xl shadow-lg transition duration-200
${isSelected ? 'ring-4 ring-yellow-400 bg-slate-600' : 'hover:bg-slate-600'}
${isSelected && isDamaged ? 'border-4 border-red-500/50' : ''}`}
onClick={onClick}
>
<div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-extrabold text-white">{data.icon} {data.name}</h3>
{isDamaged && (
<span className="text-xs font-bold text-red-400 border border-red-400 px-2 py-1 rounded-full">DAMAGED</span>
)}
</div>
<p className="text-sm text-slate-300">HP: {aircraft.health}/{stats.HP}</p>
<div className="w-full bg-slate-500 rounded-full h-2.5 mb-3">
<div
className="h-2.5 rounded-full transition-all duration-500"
style={{ width: `${healthPercent}%`, backgroundColor: healthPercent > 50 ? '#4ADE80' : healthPercent > 20 ? '#FBBF24' : '#F87171' }}
></div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-slate-300">
<StatPill Icon={Zap} label="A2A ATK" value={stats.A2A} color="bg-indigo-900/50 text-indigo-300" />
<StatPill Icon={Target} label="A2G ATK" value={stats.A2G} color="bg-green-900/50 text-green-300" />
<StatPill Icon={Aperture} label="EVADE" value={stats.EVADE} color="bg-cyan-900/50 text-cyan-300" />
<StatPill Icon={Shield} label="G-DEF" value={stats.G_DEF_Level} color="bg-red-900/50 text-red-300" />
</div>
<div className="mt-3 pt-3 border-t border-slate-600 flex flex-col gap-2">
{/* 修理ボタン */}
<button
onClick={(e) => { e.stopPropagation(); onRepair(aircraft.id); }}
className={`w-full p-2 text-sm font-bold rounded-lg transition disabled:opacity-50 flex items-center justify-center gap-2 ${
isDamaged && canAffordRepair ? 'bg-yellow-600 hover:bg-yellow-700 text-slate-900' : 'bg-slate-600 text-slate-400'
}`}
disabled={!isDamaged || !canAffordRepair}
>
<Wrench size={16}/>
{isDamaged
? canAffordRepair
? `修理 (${formatCredits(repairCost)})`
: `CR不足 (${formatCredits(repairCost)})`
: 'HP満タン'}
</button>
<div className="flex justify-between items-center">
{/* 選択ボタン */}
<button
onClick={(e) => { e.stopPropagation(); onClick(aircraft.id); }}
className={`px-3 py-1 text-sm font-bold rounded-lg transition w-1/2 mr-2 ${isSelected ? 'bg-green-500' : 'bg-indigo-600 hover:bg-indigo-700'}`}
>
{isSelected ? '搭乗中' : '搭乗する'}
</button>
{/* 売却ボタン */}
<button
onClick={(e) => { e.stopPropagation(); onSell(aircraft.id); }}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition disabled:opacity-50 w-1/2 ml-2"
disabled={pilotData?.hangar.length === 1}
>
売却 ({formatCredits(resaleValue)})
</button>
</div>
</div>
</div>
);
};
const renderMain = () => (
<div className="p-4 md:p-8 max-w-xl mx-auto space-y-8">
<h2 className="text-3xl font-extrabold text-white border-b border-yellow-500 pb-2">ブリーフィングエリア</h2>
{/* 設定セクション */}
<div className="bg-slate-800 p-4 rounded-xl shadow-2xl space-y-3">
<h3 className="text-xl font-bold text-yellow-400 flex items-center gap-2">
<Wrench size={24}/> 設定
</h3>
<div className="space-y-3">
{/* 主人公の名前設定 */}
<div>
<label className="block text-sm font-medium text-slate-300">主人公パイロット名</label>
<input
type="text"
name="pilotName" // inputの名前を設定
// **ローカルの一時状態を使用 (初期値はスペース)**
value={pilotNameTemp}
onCompositionStart={() => setIsComposing(true)} // IME変換開始
onCompositionEnd={(e) => { // IME変換終了
setIsComposing(false);
// 変換確定時にDBに書き込む (trim()で前後のスペースを除去)
updatePilotData({ pilotName: e.target.value.trim() });
}}
onChange={(e) => { // 文字入力が発生
setPilotNameTemp(e.target.value); // ローカル状態は即時更新
if (!isComposing) {
// 変換中でなければDBに書き込む (英数字入力など)
updatePilotData({ pilotName: e.target.value.trim() });
}
}}
onBlur={(e) => { // フォーカスが外れたとき
// 最終確定値をDBに書き込む (trim()で前後のスペースを除去)
const finalValue = e.target.value.trim();
if (pilotData.pilotName.trim() !== finalValue) {
updatePilotData({ pilotName: finalValue });
}
}}
className="mt-1 block w-full rounded-md bg-slate-700 border-slate-600 text-white shadow-sm p-2 focus:ring-yellow-500 focus:border-yellow-500"
placeholder="例: エース・アルフ"
/>
</div>
{/* 基地司令官の名前設定 */}
<div>
<label className="block text-sm font-medium text-slate-300">基地司令官の名前</label>
<input
type="text"
name="commanderName" // inputの名前を設定
// **ローカルの一時状態を使用 (初期値はスペース)**
value={commanderNameTemp}
onCompositionStart={() => setIsComposing(true)} // IME変換開始
onCompositionEnd={(e) => { // IME変換終了
setIsComposing(false);
// 変換確定時にDBに書き込む (trim()で前後のスペースを除去)
updatePilotData({ commanderName: e.target.value.trim() });
}}
onChange={(e) => { // 文字入力が発生
setCommanderNameTemp(e.target.value); // ローカル状態は即時更新
if (!isComposing) {
// 変換中でなければDBに書き込む (英数字入力など)
updatePilotData({ commanderName: e.target.value.trim() });
}
}}
onBlur={(e) => { // フォーカスが外れたとき
// 最終確定値をDBに書き込む (trim()で前後のスペースを除去)
const finalValue = e.target.value.trim();
if (pilotData.commanderName.trim() !== finalValue) {
updatePilotData({ commanderName: finalValue });
}
}}
className="mt-1 block w-full rounded-md bg-slate-700 border-slate-600 text-white shadow-sm p-2 focus:ring-yellow-500 focus:border-yellow-500"
placeholder="例: ミリィ"
/>
</div>
</div>
</div>
{/* パイロットステータス表示 */}
<div className="bg-slate-800 p-4 rounded-xl shadow-2xl space-y-3">
<h3 className="text-xl font-bold text-cyan-400 flex items-center gap-2">
<BarChart size={24}/> パイロットステータス (平均Lv: {currentPilotLevel})
</h3>
<div className="grid grid-cols-2 gap-3">
<PilotStatPill
label="A2A 攻撃力"
stat={pilotData.pilotStats.A2A_ATK}
color="text-indigo-300"
icon={<Zap size={20} className="text-indigo-400"/>}
/>
<PilotStatPill
label="A2G 攻撃力"
stat={pilotData.pilotStats.A2G_ATK}
color="text-green-300"
icon={<Target size={20} className="text-green-400"/>}
/>
<PilotStatPill
label="A-DEF 防御力"
stat={pilotData.pilotStats.A_DEF}
color="text-cyan-300"
icon={<Aperture size={20} className="text-cyan-400"/>}
/>
<PilotStatPill
label="G-DEF 防御力"
stat={pilotData.pilotStats.G_DEF}
color="text-red-300"
icon={<Shield size={20} className="text-red-400"/>}
/>
</div>
</div>
{/* 現在の機体表示 */}
<div className="bg-slate-800 p-4 rounded-xl shadow-2xl space-y-3">
<h3 className="text-xl font-bold text-yellow-400 flex items-center gap-2">
<Plane size={24}/> 現在の搭乗機 (統合性能)
</h3>
{currentAircraft ? (
<AircraftCard
aircraft={currentAircraft}
isSelected={true}
onClick={() => setMode('HANGAR')}
onSell={() => {}} // メイン画面では売却不可
onRepair={() => setMode('HANGAR')} // メイン画面では修理不可 (ハンガーへ誘導)
currentCredits={pilotData.credits}
/>
) : (
<p className="text-red-400">搭乗機がありません!ハンガーで購入または選択してください。</p>
)}
</div>
{/* 新規ミッションボタン */}
<button
onClick={generateAvailableMissions}
disabled={!currentAircraft}
className="w-full p-4 text-xl font-extrabold bg-green-600 hover:bg-green-700 rounded-lg flex items-center justify-center gap-3 transition disabled:bg-green-900/50 disabled:opacity-70"
>
<ListPlus size={24}/> ミッションオファーを取得
</button>
{/* ハンガーボタン */}
<button
onClick={() => setMode('HANGAR')}
className="w-full p-4 text-xl font-extrabold bg-indigo-600 hover:bg-indigo-700 rounded-lg flex items-center justify-center gap-3 transition"
>
<DollarSign size={24}/> ハンガー / 機体購入・整備
</button>
{/* 履歴 */}
<div className="bg-slate-800 p-4 rounded-xl shadow-inner max-h-60 overflow-y-auto">
<h3 className="text-lg font-bold text-slate-300 border-b border-slate-600 pb-2 mb-2">ミッション履歴</h3>
{pilotData.missionHistory.slice(-5).reverse().map((m, index) => (
<div key={index} className="flex justify-between items-center text-sm py-1 border-b border-slate-700 last:border-b-0">
<span className={m.success ? 'text-green-400' : 'text-yellow-400'}>
{m.success ? '成功' : '失敗'}
</span>
<span className="text-slate-300">{m.aircraft}</span>
<span className={m.lost ? 'text-red-400 font-bold' : 'text-slate-400'}>
{m.lost ? `機体損失 (${m.causeOfDeath === 'AIR' ? '対空' : '対地'})` : `報酬: ${formatCredits(m.reward)}`}
</span>
</div>
))}
{pilotData.missionHistory.length === 0 && <p className="text-slate-500">ミッション履歴はありません。</p>}
</div>
</div>
);
const renderMissionSelection = () => (
<div className="p-4 md:p-8 max-w-4xl mx-auto space-y-8">
<div className="flex items-center gap-4 border-b border-green-500 pb-2">
<button onClick={() => setMode('MAIN')} className="text-green-400 hover:text-green-300"><ArrowLeft size={24}/></button>
<h2 className="text-3xl font-extrabold text-green-400">ミッション選択</h2>
</div>
<p className="text-slate-300">以下のオファーから、現在の機体とパイロットスキルに合った仕事を選んでください。</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{availableMissions.map((mission, index) => (
<div key={mission.id} className="bg-slate-800 p-4 rounded-xl shadow-2xl space-y-3 border-t-4 border-yellow-500 hover:border-yellow-300 transition duration-200">
<h3 className="text-xl font-extrabold text-white flex items-center gap-2">
<Check size={20} className="text-yellow-400"/> オファー {index + 1} ({mission.profileName})
</h3>
<p className="text-lg font-bold text-yellow-300">{mission.target}</p>
<p className="text-sm text-slate-400">難易度スコア: <span className="font-bold text-white">{mission.difficultyScore}</span> / 100</p>
<div className="grid grid-cols-2 gap-2">
<StatPill Icon={Zap} label="空中脅威" value={mission.threats.air} color="bg-red-900/50 text-red-300" />
<StatPill Icon={Target} label="地上脅威" value={mission.threats.ground} color="bg-red-900/50 text-red-300" />
<StatPill Icon={Heart} label="友軍支援" value={mission.allySupport} color="bg-blue-900/50 text-blue-300" />
<StatPill Icon={DollarSign} label="報酬" value={formatCredits(mission.reward)} color="bg-yellow-900/50 text-yellow-300" />
</div>
<button
onClick={() => handleMissionSelect(mission)}
className="w-full p-3 mt-3 text-lg font-extrabold bg-indigo-600 hover:bg-indigo-700 rounded-lg transition"
>
このミッションを選択
</button>
</div>
))}
</div>
</div>
);
const renderHangar = () => (
<div className="p-4 md:p-8 max-w-4xl mx-auto space-y-8">
<div className="flex items-center gap-4 border-b border-indigo-500 pb-2">
<button onClick={() => setMode('MAIN')} className="text-indigo-400 hover:text-indigo-300"><ArrowLeft size={24}/></button>
<h2 className="text-3xl font-extrabold text-indigo-400">ハンガー / 機体購入・整備</h2>
</div>
<p className="text-lg font-bold text-yellow-400">🔧 修理コスト: HP 1ポイントにつき {formatCredits(REPAIR_COST_PER_HP)}</p>
{/* 所有機体リスト */}
<div className="bg-slate-800 p-4 rounded-xl shadow-2xl">
<h3 className="text-2xl font-bold text-yellow-300 mb-4">あなたの所有機 ({pilotData.hangar.length})</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pilotData.hangar.map(aircraft => {
const data = AIRCRAFT_DATA[aircraft.code];
// ハンガー内の機体の stats はパイロットスキルを統合したものを使う
const fullAircraft = {
...aircraft,
data,
stats: getCombinedStats(aircraft.code, pilotData.pilotStats)
};
const isSelected = aircraft.id === pilotData.currentAircraftId;
return (
<AircraftCard
key={aircraft.id}
aircraft={fullAircraft}
isSelected={isSelected}
onClick={() => handleSelectAircraft(aircraft.id)}
onSell={handleSellAircraft}
onRepair={handleRepairAircraft} // 新しいハンドラを渡す
currentCredits={pilotData.credits}
/>
);
})}
</div>
</div>
{/* 購入可能機体リスト */}
<div className="bg-slate-800 p-4 rounded-xl shadow-2xl">
<h3 className="text-2xl font-bold text-green-300 mb-4">購入可能な機体</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(AIRCRAFT_DATA)
.filter(([, data]) => data.price > 0)
.map(([code, data]) => (
<div key={code} className="bg-slate-700 p-4 rounded-xl shadow-lg">
<h4 className="text-xl font-extrabold text-white mb-2">{data.icon} {data.name}</h4>
<p className="text-sm text-slate-300 mb-3">{data.type} ({data.era})</p>
<div className="grid grid-cols-2 gap-2 text-sm text-slate-300 mb-3">
<StatPill Icon={Zap} label="A2A" value={data.stats.A2A} color="bg-indigo-900/50 text-indigo-300" />
<StatPill Icon={Target} label="A2G" value={data.stats.A2G} color="bg-green-900/50 text-green-300" />
</div>
<button
onClick={() => handleBuyAircraft(code)}
disabled={pilotData.credits < data.price}
className="w-full p-2 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg transition disabled:bg-slate-600 mt-2"
>
{pilotData.credits < data.price ? `クレジット不足 (${formatCredits(data.price)})` : `購入 (${formatCredits(data.price)})`}
</button>
</div>
))}
</div>
</div>
</div>
);
const renderMissionBrief = () => {
const briefing = getCommanderBriefing(pilotData.pilotStats, currentMission, pilotData.commanderName);
return (
<div className="p-4 md:p-8 max-w-xl mx-auto space-y-8">
<div className="flex items-center gap-4 border-b border-green-500 pb-2">
<button onClick={() => setMode('MISSION_SELECTION')} className="text-green-400 hover:text-green-300"><ArrowLeft size={24}/></button>
<h2 className="text-3xl font-extrabold text-green-400">ミッションブリーフィング</h2>
</div>
{/* 司令官のメッセージ */}
<div className={`bg-slate-800 p-4 rounded-xl shadow-2xl border-l-4 ${briefing.tone === 'love' ? 'border-pink-500' : briefing.tone === 'veteran' ? 'border-yellow-500' : 'border-red-500'}`}>
<p className="text-lg font-bold text-white mb-2">基地司令 {pilotData.commanderName.trim() || '司令官ミリィ'} からの通信</p>
<p className="text-slate-300 mb-3 italic">「{briefing.opening}」</p>
<p className="text-white font-semibold mb-4">{briefing.detail}</p>
<p className="text-slate-300 italic">「{briefing.closing}」</p>
</div>
<div className="bg-slate-800 p-4 rounded-xl shadow-2xl space-y-4">
<p className="text-lg font-bold text-yellow-300">目標: {currentMission.target}</p>
<p className="text-sm text-slate-400">難易度スコア: <span className="font-bold text-white">{currentMission.difficultyScore}</span> / 100</p>
<div className="grid grid-cols-2 gap-4">
<StatPill Icon={Zap} label="空中脅威" value={currentMission.threats.air} color="bg-red-900/50 text-red-300" />
<StatPill Icon={Target} label="地上脅威" value={currentMission.threats.ground} color="bg-red-900/50 text-red-300" />
<StatPill Icon={Heart} label="友軍支援" value={currentMission.allySupport} color="bg-blue-900/50 text-blue-300" />
<StatPill Icon={DollarSign} label="報酬" value={formatCredits(currentMission.reward)} color="bg-yellow-900/50 text-yellow-300" />
</div>
</div>
{/* 搭乗機体情報 */}
<div className="bg-slate-800 p-4 rounded-xl shadow-2xl">
<h3 className="text-xl font-bold text-indigo-300 mb-3">あなたの機体 (統合性能)</h3>
{currentAircraft && (
<div className="text-sm space-y-1">
<p className="text-white font-bold">{currentAircraft.data.icon} {currentAircraft.data.name}</p>
<p className="text-slate-400">
A2A: <span className="text-indigo-300">{currentAircraft.stats.A2A}</span>,
A2G: <span className="text-green-300">{currentAircraft.stats.A2G}</span>,
EVADE: <span className="text-cyan-300">{currentAircraft.stats.EVADE}</span>,
G-DEF: <span className="text-red-300">{currentAircraft.stats.G_DEF_Level}</span>
</p>
{/* HPが満タンでなければ警告 */}
{currentAircraft.health < currentAircraft.maxHP ? (
<p className="text-red-400 font-bold">現在のHP: {currentAircraft.health}/{currentAircraft.maxHP} (破損状態!)</p>
) : (
<p className="text-green-400">現在のHP: {currentAircraft.health}/{currentAircraft.maxHP} (万全)</p>
)}
</div>
)}
</div>
<button
onClick={runMissionSimulation}
disabled={isSimulating || !currentAircraft}
className="w-full p-4 text-xl font-extrabold bg-red-600 hover:bg-red-700 rounded-lg flex items-center justify-center gap-3 transition disabled:bg-red-900/50"
>
{isSimulating ? <Loader2 size={24} className="animate-spin" /> : <Plane size={24}/>}
{isSimulating ? '出撃中...' : 'ミッション開始!'}
</button>
</div>
);
};
const renderMissionResult = () => {
// 履歴データがまだ取得できていない場合の安全チェック
if (!pilotData || pilotData.missionHistory.length === 0) return null;
const lastMission = pilotData.missionHistory[pilotData.missionHistory.length - 1];
// 撃墜されたか否かで、HP情報と機体名をどこから取得するか切り替える
const finalHP = lastMission.lost
? 0 // 撃墜ならHPは0
: pilotData.hangar.find(a => a.id === pilotData.currentAircraftId)?.health || 0;
// 機体の最大HPを履歴または現在の機体から取得
const maxHP = lastMission.aircraftMaxHP || currentAircraft?.maxHP || 100;
const finalAircraftName = lastMission.aircraft;
const resultColor = lastMission.success && !lastMission.lost ? 'bg-green-600' : 'bg-red-600';
const resultMessage = lastMission.lost ? (
<span className="text-yellow-300">機体は失われましたが、パイロットは緊急脱出に成功しました。</span>
) : lastMission.success ? (
<span className="text-green-300">ミッション成功!</span>
) : (
<span className="text-red-300">ミッション失敗...</span>
);
let lamentData = null;
if (lastMission.lost) {
const pilotLevel = Math.floor(Object.values(pilotData.pilotStats).reduce((sum, stat) => sum + stat.level, 0) / 4);
lamentData = getCommanderLament(
pilotLevel,
pilotData.pilotName, // DBの値(スペース含む)
pilotData.commanderName,
lastMission.causeOfDeath
);
}
return (
<div className="p-4 md:p-8 max-w-xl mx-auto space-y-8">
<h2 className={`text-3xl font-extrabold text-center p-4 rounded-xl ${resultColor}`}>
{lastMission.lost ? '機体損失 - MISSION ABORTED' : lastMission.success ? 'MISSION ACCOMPLISHED' : 'MISSION FAILED'}
</h2>
<div className="bg-slate-800 p-4 rounded-xl shadow-2xl space-y-4">
<h3 className="text-xl font-bold text-white">戦闘結果サマリー</h3>
<div className="text-lg font-semibold border-b border-slate-700 pb-2">
<p>機体: <span className="text-indigo-300">{finalAircraftName}</span></p>
<p>結果: {resultMessage}</p>
{lastMission.lost && (
<p className="text-red-400 font-bold">死亡原因: {lastMission.causeOfDeath === 'AIR' ? '対空攻撃 (敵制空隊)' : '対地攻撃 (SAM/対空砲)'}</p>
)}
</div>
<div className="space-y-2">
<p className="text-slate-400">獲得クレジット:</p>
<p className="text-3xl font-extrabold text-yellow-400">{formatCredits(lastMission.reward)}</p>
</div>
{/* 生存時、または撃墜時でもHP表示を安全に切り替える */}
{!lastMission.lost && (
<div className="space-y-2">
<p className="text-slate-400">最終HP:</p>
<p className="text-2xl font-extrabold text-white">{finalHP} / {maxHP}</p>
{finalHP < maxHP && (
<p className="text-sm text-yellow-400">機体が破損しています。ハンガーで修理が必要です!</p>
)}
</div>
)}
{/* 司令官の呟き (撃墜時のみ) */}
{lamentData && (
<div className="bg-slate-700 p-4 rounded-lg mt-4 border-l-4 border-red-500">
<p className="text-sm font-bold text-red-400 mb-2">基地司令 {lamentData.commanderName} の呟き</p>
<p className="text-white italic whitespace-pre-wrap">{lamentData.lament}</p>
</div>
)}
{lastMission.lost && pilotData.hangar.length === 0 && pilotData.credits < AIRCRAFT_DATA[INITIAL_AIRCRAFT_CODE].price && (
<p className="text-lg font-extrabold text-red-400 border-t border-slate-700 pt-2">
😭 クレジット不足で機体購入不可!ミッション失敗により契約終了...。
</p>
)}
</div>
{/* 詳細ログ */}
<div className="bg-slate-700 h-60 overflow-y-auto p-3 rounded-lg font-mono text-sm space-y-1">
<h3 className="text-base font-bold text-white sticky top-0 bg-slate-700/90 py-1">詳細ログ</h3>
{missionLog.map((entry, index) => (
<p key={index} className={`py-0.5 rounded-sm ${entry.type === 'SUCCESS' || entry.type === 'LEVELUP' ? 'text-green-300' : entry.type === 'FAILURE' || entry.type === 'CRASH' ? 'text-red-300' : entry.type === 'DAMAGE' ? 'text-yellow-300' : 'text-slate-400'}`}>
{entry.text}
</p>
))}
</div>
<button
onClick={() => setMode('MAIN')}
className="w-full p-4 text-xl font-extrabold bg-indigo-600 hover:bg-indigo-700 rounded-lg flex items-center justify-center gap-3 transition"
>
<ArrowLeft size={24}/> メインメニューに戻る
</button>
</div>
);
};
// --- 13. ロード中/エラー処理 ---
if (loading || !isAuthReady || !pilotData) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white p-4">
<Loader2 size={48} className="animate-spin text-indigo-400" />
<p className="mt-4 text-xl">
{error ? 'エラー発生' : 'フライトシステム起動中...'}
</p>
{error && <p className="mt-2 text-red-400 text-center">{error}</p>}
{/* 認証が完了していない場合 */}
{!isAuthReady && <p className="mt-2 text-slate-500">認証プロトコル確立中...</p>}
</div>
);
}
// 機体が選択されていない場合の強制遷移
if (pilotData.hangar.length > 0 && !pilotData.currentAircraftId) {
// 最初の機体を選択して、ハンガーに誘導
const firstAircraftId = pilotData.hangar[0].id;
// DBに書き込み、onSnapshotで状態が更新される
handleSelectAircraft(firstAircraftId);
// 画面はハンガーに強制遷移
setMode('HANGAR');
} else if (pilotData.hangar.length === 0 && pilotData.credits < AIRCRAFT_DATA[INITIAL_AIRCRAFT_CODE].price) {
// 資金切れで続行不可能
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white p-4">
<h2 className="text-4xl font-extrabold text-red-500">GAMEOVER</h2>
<p className="mt-4 text-xl text-center">
傭兵としてのキャリアは終了しました。<br/>
機体がなく、機体を再購入するクレジットも残っていません。
</p>
<p className="mt-8 text-lg text-yellow-400">
現在のクレジット: {formatCredits(pilotData.credits)}
</p>
</div>
);
} else if (pilotData.hangar.length === 0) {
// 機体がないが、クレジットはある -> ハンガーへ強制遷移
setMode('HANGAR');
}
// --- 14. メインレイアウト ---
return (
<div className="min-h-screen bg-slate-900 text-white font-sans">
<header className="flex justify-between items-center p-4 bg-slate-800 shadow-xl sticky top-0 z-10">
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Zap size={28} className="text-red-400"/> MERCENARY SIM
</h1>
<div className="text-right">
{/* 名前の表示もtrim()して、もし空だったら「主人公」を表示する */}
<p className="text-sm font-semibold text-slate-300">パイロット: {pilotData.pilotName.trim() || '主人公'}</p>
<p className="text-xl font-extrabold text-yellow-400 flex items-center gap-1">
<DollarSign size={20}/> {formatCredits(pilotData.credits)}
</p>
</div>
</header>
<main>
{mode === 'MAIN' && renderMain()}
{mode === 'HANGAR' && renderHangar()}
{mode === 'MISSION_SELECTION' && renderMissionSelection()}
{mode === 'MISSION_BRIEF' && renderMissionBrief()}
{mode === 'MISSION_RESULT' && renderMissionResult()}
</main>
<footer className="p-4 text-center text-xs text-slate-600 border-t border-slate-700 mt-8">
Pilot ID: {userId.slice(0, 8)}... | Data saved via Firestore.
</footer>
</div>
);
};
export default App;
