/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useEffect, useRef } from "react"; import { UserProgression, MatchMode, AmmoBelt, CampaignMissionDefinition } from "../types"; import { KnownMaps } from "../game/content/maps/mapTypes"; import { MAP_REGISTRY } from "../game/content/maps/registry"; import { DEFAULT_AIRCRAFT } from "../game/aircraftData"; import { CAMPAIGN_MISSIONS } from "../game/content/campaign/campaignMissions"; import { Lock, Sparkles, ChevronRight, ChevronLeft, Coins, User, ListTodo, ChevronDown, Wrench } from "lucide-react"; import { PlanePreview3D } from "./PlanePreview3D"; import { getMultiplayerSessionId } from "../hooks/useProgression"; interface MainMenuProps { progression: UserProgression; onLaunchMatch: ( selectedPlane: string, belt: AmmoBelt, mods: string[], mapId: string, mode: MatchMode, isMultiplayer: boolean, startOnGround?: boolean, campaignMissionId?: string ) => void; onUpdateProgression: (updated: UserProgression) => void; onOpenRegistration: () => void; } enum HangarTab { Lobby = "PLAY", Campaign = "CAMPAIGN", Hangar = "HANGAR", Skins = "SKINS", Shop = "SHOP" } interface SkinPreset { id: string; name: string; description: string; cost: number; unlockedByDefault: boolean; hex: string; } export const MainMenu: React.FC = ({ progression, onLaunchMatch, onUpdateProgression, onOpenRegistration }) => { const [activeTab, setActiveTab] = useState(HangarTab.Lobby); const [selectedPlaneId, setSelectedPlaneId] = useState(progression.selectedPlaneId || "falcon-mk2"); const [selectedBelt, setSelectedBelt] = useState(progression.selectedBelt || AmmoBelt.Universal); const [selectedMapId, setSelectedMapId] = useState(KnownMaps.VolcanicTerrain); const [selectedMode, setSelectedMode] = useState(MatchMode.AirSupremacy); // Custom persist simulated Gold currency const [gold, setGold] = useState(() => { const saved = localStorage.getItem("airframe_gold"); return saved ? parseInt(saved) : 850; }); // Daily Quests Claim System state const [unlockedSkins, setUnlockedSkins] = useState(() => { const saved = localStorage.getItem("airframe_unlocked_skins"); return saved ? JSON.parse(saved) : ["default"]; }); const [claimedDaily, setClaimedDaily] = useState(false); const [liveCounts, setLiveCounts] = useState<{ total: number; byQueue: Record }>({ total: 0, byQueue: {} }); const [liveBlips, setLiveBlips] = useState<{ team: 1 | 2; nx: number; ny: number }[]>([]); useEffect(() => { const pollHealth = async () => { try { const t = getMultiplayerSessionId(); const r = await fetch(`/api/health?t=${encodeURIComponent(t)}`); if (r.ok) { const d = await r.json(); setLiveCounts({ total: d.totalPlayers ?? 0, byQueue: d.byQueue ?? {} }); } } catch { /* offline */ } }; const pollPreview = async () => { try { const r = await fetch("/api/preview"); if (r.ok) { const d = await r.json(); const WORLD_R = 18000; const blips = (d.players ?? []).map((p: { team: number; x: number; z: number }) => ({ team: p.team as 1 | 2, nx: Math.min(1, Math.max(0, (p.x + WORLD_R) / (WORLD_R * 2))), ny: Math.min(1, Math.max(0, (p.z + WORLD_R) / (WORLD_R * 2))) })); setLiveBlips(blips); } } catch { /* offline */ } }; pollHealth(); pollPreview(); const hi = setInterval(pollHealth, 6000); const pi = setInterval(pollPreview, 3000); return () => { clearInterval(hi); clearInterval(pi); }; }, []); // Ready Room UI Popups States const [_showModeDropdown, setShowModeDropdown] = useState(false); const [showLoadoutDrawer, setShowLoadoutDrawer] = useState(false); const [showQuestDrawer, setShowQuestDrawer] = useState(false); const [showCoinsDrawer, setShowCoinsDrawer] = useState(false); // Active plane specifications definitions const currentPlane = DEFAULT_AIRCRAFT.find(a => a.id === selectedPlaneId) || DEFAULT_AIRCRAFT[0]; const isPlaneUnlocked = progression.unlockedPlanes.includes(selectedPlaneId); // Pilot stats and level calculations const playerXP = progression.totalXp; const playerLevel = Math.floor(playerXP / 1500) + 1; const xpCurrentLevel = playerXP % 1500; const xpNextLevelPercent = Math.min(100, Math.floor((xpCurrentLevel / 1500) * 100)); // Skins custom presets const SKIN_PRESETS: SkinPreset[] = [ { id: "default", name: "Raw Alloy Gray", description: "Matte factory titanium shielding finish.", cost: 0, unlockedByDefault: true, hex: "#6b7280" }, { id: "camo", name: "Royal Camouflage", description: "Classic RFC forest green and clay brown camouflage.", cost: 800, unlockedByDefault: false, hex: "#2d5a27" }, { id: "crimson", name: "Crimson Devil", description: "Intense burning blood-red decals with dark wing stripes.", cost: 1500, unlockedByDefault: false, hex: "#991b1b" }, { id: "carbon", name: "Carbon Void", description: "Gloss dark carbon fiber weave with neon cyan indicators.", cost: 2400, unlockedByDefault: false, hex: "#111827" }, { id: "gold", name: "Golden Ace", description: "Polished celestial solid gold coating for supreme pilots.", cost: 4000, unlockedByDefault: false, hex: "#eab308" } ]; const activeSkinId = progression.customizations?.skin || "default"; const activeSkin = SKIN_PRESETS.find(s => s.id === activeSkinId) || SKIN_PRESETS[0]; // Save Gold and Skins state to localStorage useEffect(() => { localStorage.setItem("airframe_gold", gold.toString()); }, [gold]); useEffect(() => { localStorage.setItem("airframe_unlocked_skins", JSON.stringify(unlockedSkins)); }, [unlockedSkins]); // Handle outside clicks to close popups const popoverRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { // We let individual buttons handle themselves, or simplify to closing everything when clicking backdrop center. } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Handlers const handleUnlockPlane = (planeId: string, cost: number) => { if (progression.totalXp >= cost && !progression.unlockedPlanes.includes(planeId)) { const updated: UserProgression = { ...progression, totalXp: progression.totalXp - cost, unlockedPlanes: [...progression.unlockedPlanes, planeId] }; onUpdateProgression(updated); } }; const handleEquipPlane = (planeId: string) => { const updated: UserProgression = { ...progression, selectedPlaneId: planeId }; onUpdateProgression(updated); setSelectedPlaneId(planeId); }; const availableTheaters = [ { id: KnownMaps.VolcanicTerrain, mode: MatchMode.AirSupremacy }, { id: KnownMaps.IslandChain, mode: MatchMode.AirSupremacy }, { id: KnownMaps.Earth3D, mode: MatchMode.AirSupremacy }, ]; const handleCycleMap = (direction: number) => { try { const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); const osc = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); osc.connect(gainNode); gainNode.connect(audioCtx.destination); osc.frequency.setValueAtTime(320, audioCtx.currentTime); osc.type = "triangle"; gainNode.gain.setValueAtTime(0.015, audioCtx.currentTime); osc.start(); osc.stop(audioCtx.currentTime + 0.05); } catch (_) {} const index = availableTheaters.findIndex(theater => theater.id === selectedMapId); const nextIndex = (index + direction + availableTheaters.length) % availableTheaters.length; const nextTheater = availableTheaters[nextIndex]; setSelectedMapId(nextTheater.id); setSelectedMode(nextTheater.mode); }; const handleUnlockSkin = (skin: SkinPreset) => { if (progression.totalXp >= skin.cost && !unlockedSkins.includes(skin.id)) { setUnlockedSkins([...unlockedSkins, skin.id]); const updated: UserProgression = { ...progression, totalXp: progression.totalXp - skin.cost, customizations: { ...progression.customizations, skin: skin.id } }; onUpdateProgression(updated); } }; const handleEquipSkin = (skinId: string) => { const updated: UserProgression = { ...progression, customizations: { ...progression.customizations, skin: skinId } }; onUpdateProgression(updated); }; const handleClaimDailyCredits = () => { if (claimedDaily) return; setClaimedDaily(true); const updated: UserProgression = { ...progression, totalXp: progression.totalXp + 600 }; onUpdateProgression(updated); }; const handleConvertGold = () => { if (progression.totalXp >= 1000) { setGold(prev => prev + 250); const updated: UserProgression = { ...progression, totalXp: progression.totalXp - 1000 }; onUpdateProgression(updated); } }; const handleToggleUpgradeMod = (modId: string) => { const currentMods = progression.equippedMods?.[selectedPlaneId] || []; let updatedMods: string[]; if (currentMods.includes(modId)) { updatedMods = currentMods.filter(m => m !== modId); } else { updatedMods = [...currentMods, modId]; } const updated: UserProgression = { ...progression, equippedMods: { ...(progression.equippedMods || {}), [selectedPlaneId]: updatedMods } }; onUpdateProgression(updated); }; const handleLaunch = () => { if (!isPlaneUnlocked) return; const finalProg: UserProgression = { ...progression, selectedPlaneId, selectedBelt }; onUpdateProgression(finalProg); const activeMods = progression.equippedMods?.[selectedPlaneId] || []; onLaunchMatch( selectedPlaneId, selectedBelt, activeMods, selectedMapId, selectedMode, true ); }; const handleLaunchCampaign = (mission: CampaignMissionDefinition) => { const missionMods = progression.equippedMods?.[mission.aircraftId] || []; onLaunchMatch( mission.aircraftId, selectedBelt, missionMods, mission.mapId, mission.mode, false, mission.startOnGround ?? false, mission.id ); }; // Upgrades list helper const COMBAT_UPGRADES = [ { id: "fuel-heavy", name: "High-Octane Engine Mix", effects: "+12% Engine Power, -5% Wing Health", slot: "Powertrain" }, { id: "engine-polishing", name: "NACA Air Intake Polish", effects: "-6% Fuselage Parasitic Drag", slot: "Aerodynamics" }, { id: "stripped-frame", name: "Precision Weight Stripping", effects: "-8% Deadweight, -10% Hitpoints", slot: "Structure" }, { id: "reinforced-skin", name: "Composite Alloy Hulling", effects: "+20% Hitpoints, +5% Drag", slot: "Armor" }, { id: "polished-guns", name: "Low-Friction Gun Gaskets", effects: "+10% Wing-Roll Speed Rate", slot: "Weapons" } ]; return (
{/* 3D WEBGL CARRIER PREVIEW BACKGROUND */}
{/* Live battle blips — shown when an active game is running */} {liveBlips.length > 0 && (
{liveBlips.map((b, i) => (
))}
LIVE MATCH IN PROGRESS
)}
AERO COMMAND HUB DECK ZONE B HEADING 285°
{/* Large Diegetic Plane Cycle Arrows on Left/Right edges of the screen */} {activeTab === HangarTab.Lobby && (() => { const curQueueKey = `${selectedMapId}_${selectedMode}`; const mapPlayerCount = liveCounts.byQueue[curQueueKey] ?? 0; const mapDef = MAP_REGISTRY[selectedMapId]; const theaterIdx = availableTheaters.findIndex(t => t.id === selectedMapId); const modeName = selectedMode === "air_supremacy" ? "AIR SUPREMACY" : selectedMode === "intercept" ? "INTERCEPT" : selectedMode === "duel_arena" ? "DUEL ARENA" : selectedMode.toUpperCase(); return ( <> {/* Left arrow — only rendered when more than one theater is available */} {availableTheaters.length > 1 && (
)} {/* Right arrow — only rendered when more than one theater is available */} {availableTheaters.length > 1 && (
)} {/* Theater nameplate — centered, just above Click to Play zone */}
{availableTheaters.length > 1 && ( THEATER {String(theaterIdx + 1).padStart(2, "0")} / {availableTheaters.length} )}

{mapDef?.name ?? selectedMapId}

{modeName} · {mapPlayerCount} IN LOBBY
{availableTheaters.length > 1 && (
{availableTheaters.map((t, i) => (
))}
)}
{/* Aircraft name bottom-left */}
ASSIGNED CRAFT

{currentPlane.name}

{currentPlane.weapons.join(" · ")}

); })()} {/* 1. TOP BAR */}
{/* Brand/Logo */}
A
AIRFRAME.IO 3D BATTLE PROTOCOL
{liveCounts.total} ONLINE
{/* Navigation Tabs */} {/* Quick Stats on Right */}
{/* Coins Indicators & Converter popup - Moved from Footer to Header */}
{/* Currency popover exchange overlay - Opened Downward from top-right */} {showCoinsDrawer && (
QUICK ACCOUNT EXCHANGE
XP TRADE REQUISITION Exchange 1,000 XP combat match earnings to gain instant gold.
)}
{activeSkinId !== "default" && ( 🎨 {activeSkin.name.toUpperCase()} )}
{progression.nickname || "GUEST_CADET"} Rank Code: {progression.rankCode || "CDT"}
{/* 2. CENTER STAGE CONTENT */}
{/* Lobby State: Click to Play center — theater nameplate and aircraft info rendered in the arrow overlay above */} {activeTab === HangarTab.Lobby && (
Click to Play
)} {/* Hangar Tab Content Showcase */} {activeTab === HangarTab.Hangar && (

FLEET HANGARS SHOWROOM

Deploy advanced aircraft utilizing XP match earnings

Vault: {progression.totalXp.toLocaleString()} XP
{DEFAULT_AIRCRAFT.map(plane => { const isUnlocked = progression.unlockedPlanes.includes(plane.id); const isSelected = selectedPlaneId === plane.id; const unlockCost = plane.id === "falcon-mk2" ? 0 : plane.id === "kite-9" ? 1800 : plane.id === "vulcan-51" ? 3000 : plane.id === "grizzly-a1" ? 4500 : plane.id === "twinwolf" ? 6000 : 0; return (
isUnlocked && handleEquipPlane(plane.id)} className={`p-3.5 rounded-lg border text-left flex flex-col justify-between h-[155px] transition-all relative select-none cursor-pointer ${ isSelected ? "bg-slate-900 border-amber-500/85 text-amber-500 font-bold ring-2 ring-amber-500/10 shadow-[0_0_15px_rgba(245,158,11,0.05)]" : isUnlocked ? "bg-slate-950/40 border-slate-850 hover:border-slate-800 hover:bg-slate-950/70" : "bg-slate-950/20 border-slate-950 opacity-60" }`} >
{plane.name}
{plane.class} {plane.description}
{!isUnlocked ? ( ) : isSelected ? ( DEPLOYED ) : ( MOUNT JET )}
); })}
)} {activeTab === HangarTab.Campaign && (

Campaign Operations

Assigned aircraft are issued as mission loaners. Complete objectives before time expires.

{CAMPAIGN_MISSIONS.map(mission => { const completed = progression.completedCampaignMissions?.includes(mission.id) ?? false; const aircraft = DEFAULT_AIRCRAFT.find(plane => plane.id === mission.aircraftId); return (
Mission {String(mission.order).padStart(2, "0")}

{mission.name}

{completed ? "Complete" : `+${mission.xpReward} XP`}

{mission.briefing}

AIRCRAFT {aircraft?.name ?? mission.aircraftId}
THEATER {mission.mapId}
OBJECTIVE {mission.targetCount} TARGETS
); })}
)} {/* Skins Tab Content Showcase */} {activeTab === HangarTab.Skins && (

AIRCRAFT AESTHETIC COATINGS

Acquire and equip visual skins onto the 3D showcase model

SHOWROOM DIRECT SYNC
{SKIN_PRESETS.map(skin => { const isUnlocked = unlockedSkins.includes(skin.id); const isEquipped = activeSkinId === skin.id; return (
{skin.name}
{skin.description}
{!isUnlocked ? ( ) : isEquipped ? ( MOUNTED ) : ( )}
); })}
)} {/* Shop Tab Content Showcase */} {activeTab === HangarTab.Shop && (

REQ BLACK-MARKET DISPATCH

Sandbox utilities & commercial credit exchanges

COMMERCIAL CONTRACTS SECURE
{/* Gold Exchange card */}
⚙️ PREMIUM EXCHANGE PACK

Expose accumulated combat XP credits to gold currencies for high-tier skins showroom customization.

{/* Sandbox full access */}
🏅 ELITE sandbox credentials VIP

Instantly deploy all locked aircrafts and gains infinite sandbox credits testing flight aerodynamics.

)}
{/* 3. BOTTOM STRIP */}
); };