大空の狼達 V1.2
多分、安定したかもなので、なんかページに書ききれないので新しく作成
普通ありえないけど、「25万文字」が限界かな?
29万文字入れようとしたらハングアップした。
(そもそも人間業ではありえんが…AIはやるからなぁ)
因みに、NOtEがメインになりはじめたのは、はてなでリプレイ書いてたらあまりにもページが足らんというあんまりあり得ない理由だったりする。
この先、JRRトールキンみたいに一つの架空世界の歴史をつづりたいとか思っててもはてなではページが何枚あっても足らんと諦めてたので
NOtEにも限界はあるみたい。
ただ、ちょっとなんか書いたら4万文字は行くから、展開を5回やったら限界、しかし、その前に「読者が飽きるだろ」たぶん。

改編内容と細かい説明はV1.1以前を参照として
まあ、あとはどう遊ぶかかな?これは

変更してますので内容は別のものが出る筈です。
アイデア次第かと
Canvas用です。
名前・設定 を最初に選んで、
パイロット名と司令官名を入力
あと、世界設定はアイデア次第、それを読んでAIがミッション構築しますからここの入力次第では化けます。
機体も大戦略っぽく揃えましたがグリペンとかラファール入れてないなそう言えば…忘れてた。
ブリーフィングで司令官といちゃコラしたら、ミッション開始
ブリーフィング情報不足を感じるなら聞き出して対策を打ち出して下さい。
それで勝率がどう変わるかはあなた次第
AIが全ての設定と判定から総合的に結果を産出します。
さて、どんなラストを向かえるのやら。
ミッションに合わせて機体選定と思ったらAIに止められた、今の機体で絶対に失敗するのを回避してブリーフィングしてるから逆効果だと
「つまりは司令官はわかってて任務与えてるんだから余計なことするな」見たいな話
言われてみればそうか…。
寧ろユーザーのチート必勝法になるからダメとか言われた、うーむ、AIの方が人間の事わかってる。
うーん、AIミッションの絶妙さかなあ…この辺は
まぁ、言える事は、「ぜんぜん自分の理想には追い付かないけどそれでもここまでできる事はすごい」
仕事には不安定過ぎて使えないけど、遊びならこの揺らぎがいい感じに機能するかも…。
テストなので傾向見てるので未来はもっと良くなると信じたい。
でも、大規模型LLMは限界見え始めてるからそろそろこの辺が限界として理想と現実をわきまえてゲーム作らないとならないのかもしれない。
<!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 {
// ★修正: 防御的構文解析を導入
// AIがよくMarkdownブロックや余分なテキストで囲むため、最初の { から最後の } までの部分を抽出する
const jsonMatch = text.match(/```json\s*(\{[\s\S]*?\})\s*```|(\{[\s\S]*?\})/);
let jsonString = null;
if (jsonMatch) {
// グループ1(コードブロック内)かグループ2(裸のJSON)のいずれかを使用
jsonString = jsonMatch[1] || jsonMatch[2];
} else {
// マッチしなかった場合は元のテキストを試す(最終手段)
jsonString = text;
}
if (jsonString) {
// 抽出したJSON文字列をパース
return JSON.parse(jsonString);
} else {
throw new Error("Could not extract potential JSON string from LLM response.");
}
} catch (e) {
console.error("Failed to parse JSON from LLM response:", text, "Error:", e);
// JSONパース失敗時は、生のAI出力テキストをコンソールに表示
console.error("Raw LLM text response causing JSON failure:", text);
// エラーを投げてexecuteMissionのFallbackに任せる
throw new Error("Invalid JSON response from LLM.");
}
}
return text;
} else {
// 応答が空、または構造が不正な場合
throw new Error("No valid content found in LLM response or empty 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.personality}
- 口調: ${COMMANDER_PROFILE.tone}
- パイロットとの関係性: 現在の経験値(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()}
現在の難易度レベルに基づいて、簡潔なミッションを提示してください。
ミッション報酬は難易度レベル1でも**最低5000ドル**に設定してください。
★★重要★★
結果を以下のJSONスキーマに従って日本語で、感情を込めて出力してください。
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": `成功時の基本報酬額。ミッションレベル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.length > 0
? `(司令官とのやり取りは${gameState.chatHistory.length}回ありました。準備状況は良好です。)`
: `(司令官とのやり取りはありませんでした。)`;
// ★ 修正点: プロンプトを極限まで簡素化し、指示に集中させる ★
const systemPrompt = `
あなたは空戦の専門家であり、作戦の評価官です。
結果を物語風に記述し、報酬を計算して、以下のJSONスキーマに従って出力してください。
【ミッション評価基準】
- 世界設定: ${gameState.worldSetting}
- 機体性能: ${currentAircraftData.display} (ランク: ${currentAircraftData.performance})
- 難易度レベル: ${gameState.missionLevel}
- 基本報酬: ${missionData.baseRewardMoney.toLocaleString()}$ (成功時の獲得資金はこれを下回らないこと)
★★最重要★★
JSON形式のオブジェクト以外は一切出力しないでください。物語の記述もJSON内の"narration"フィールドに収めてください。
`;
const userQuery = `
ミッション: ${missionData.missionTitle}
パイロット: ${gameState.pilotName}
評価基準に基づいて、作戦の結果(成否、物語、獲得資金、獲得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;
let usedFallback = false; // フォールバックを使用したかを追跡
const payload = {
contents: [{ parts: [{ text: userQuery }] }],
systemInstruction: { parts: [{ text: systemPrompt }] },
};
try {
// LLMに厳密なJSONを要求する (防御的構文解析がcallGeminiApi内で実行される)
resultData = await callGeminiApi(payload, 5, true, resultSchema);
} catch (apiError) {
// ★★★ LLM判定失敗時の緊急プロトコル (Deterministic Fallback) ★★★
usedFallback = true;
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');
// ★修正点: ユーザーへの通知を追加 ★
showAlert("緊急プロトコル発動 (通信障害)", "AIからの判定結果を受信できませんでした。自動評価(成功率70%)を適用し、結果を確定します。通信環境をご確認ください。");
}
// ★★★ 緊急プロトコル終了 ★★★
// AI判定または緊急プロトコルで得られた結果を処理
// 報酬の強制保証 (AI判定が成功した場合のみ)
if (resultData.success) {
const guaranteedMoney = Math.max(resultData.moneyEarned, missionData.baseRewardMoney);
if (guaranteedMoney > resultData.moneyEarned && !usedFallback) {
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');
if (!usedFallback) {
// AI判定での失敗の場合のみ追加で機体喪失アラート
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.xp.toLocaleString()} XP
- 世界設定: ${gameState.worldSetting}
結果を物語風に記述し、ゲームの最終的な結末として提示してください。
結果はJSONスキーマに従って出力してください。
★★最重要★★ JSON形式のオブジェクト以外は一切出力しないでください。
`;
const userQuery = `
最終目標: プロジェクト・ロンギヌス
パイロット: ${gameState.pilotName}
使用機体: ${currentAircraftData.display} (性能ランク: ${currentAircraftData.performance})
この条件で、作戦の結果(成否、物語)を判定してください。最終ミッションのため、報酬の計算は不要です。
`;
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>
