import { useState, useEffect, useRef, useCallback, MutableRefObject } from "react"; import { Vector3, Quaternion } from "three"; import { MatchSimulation } from "../game/matchSimulation"; import { WorldRenderer } from "../game/worldRenderer"; import { InputManager } from "../game/inputManager"; import { FlightPhysicsEngine } from "../game/flightModel"; import { MAP_REGISTRY } from "../game/content/maps/registry"; import { KnownMaps } from "../game/content/maps/mapTypes"; import { loadHeightmap } from "../game/terrainModel"; import { MatchMode, AmmoBelt, Pilot, KillEvent, ControlMode, SkyZone, CameraMode, BombSightInfo, CampaignMissionState, WeaponType, GroundTarget, UserProgression } from "../types"; export type HudSnapshot = { pilots: Pilot[]; groundTargets: GroundTarget[]; zones: SkyZone[]; killFeed: KillEvent[]; team1Score: number; team2Score: number; matchTimer: number; bombSightInfo: BombSightInfo | null; campaignState: CampaignMissionState | null; }; export function beep(freq: number, type: OscillatorType, length: 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(freq, audioCtx.currentTime); osc.type = type; gainNode.gain.setValueAtTime(0.12, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + length); osc.start(audioCtx.currentTime); osc.stop(audioCtx.currentTime + length); } catch (_) { // Ignore audio errors } } export function useGameEngine( progression: UserProgression, saveProgression: (updated: UserProgression) => void, multiplayer: { connectMultiplayer: any; disconnectMultiplayer: any; roomRef: MutableRefObject; } ) { const [isPlaying, setIsPlaying] = useState(false); const [showDebrief, setShowDebrief] = useState(false); const [debriefData, setDebriefData] = useState<{ victory: boolean; xpEarned: number; kills: number; structures: number; missionName?: string; } | null>(null); const canvasContainerRef = useRef(null); const renderer3DRef = useRef(null); const inputManagerRef = useRef(null); const activeEngineRef = useRef(null); const [activeEngine, setActiveEngine] = useState(null); const [cameraMode, setCameraMode] = useState("third-person"); const cameraModeRef = useRef("third-person"); const [hudSnapshot, setHudSnapshot] = useState({ pilots: [], groundTargets: [], zones: [], killFeed: [], team1Score: 0, team2Score: 0, matchTimer: 360, bombSightInfo: null, campaignState: null }); const [activeMatchMode, setActiveMatchMode] = useState(MatchMode.AirSupremacy); const [hitmarker, setHitmarker] = useState<{ active: boolean; type: "air" | "ground"; key: number }>({ active: false, type: "air", key: 0 }); const [killPopups, setKillPopups] = useState>([]); const killPopupKeyRef = useRef(0); const [isPaused, setIsPaused] = useState(false); const isPausedRef = useRef(false); const [showTacticalMap, setShowTacticalMap] = useState(false); const togglePause = useCallback(() => { const newVal = !isPausedRef.current; isPausedRef.current = newVal; setIsPaused(newVal); if (newVal) setShowTacticalMap(false); }, []); const setActiveCameraMode = useCallback((next: CameraMode) => { cameraModeRef.current = next; setCameraMode(next); renderer3DRef.current?.setCameraMode(next, "player"); }, []); const toggleCameraMode = useCallback(() => { setActiveCameraMode(cameraModeRef.current === "first-person" ? "third-person" : "first-person"); }, [setActiveCameraMode]); const toggleBombSight = useCallback(() => { const player = activeEngineRef.current?.pilots.find(p => p.id === "player"); const canUseBombSight = cameraModeRef.current === "bombsight" || (player && player.specs.weapons.includes(WeaponType.BOMB) && (player.ammo[WeaponType.BOMB] ?? 0) > 0); if (!canUseBombSight) return; setActiveCameraMode(cameraModeRef.current === "bombsight" ? "third-person" : "bombsight"); }, [setActiveCameraMode]); useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement) return; if (e.key === "Escape") { if (isPlaying) { e.preventDefault(); if (showTacticalMap) setShowTacticalMap(false); else togglePause(); } } if (e.key === "Delete" && isPlaying) { const eng = activeEngineRef.current; if (!eng) return; const player = eng.pilots.find(p => p.id === "player"); if (player && player.damage.fuselage > 0) { const speed = Math.sqrt(player.vx ** 2 + player.vy ** 2 + player.vz ** 2); const wingsGone = player.damage.leftWing <= 0 || player.damage.rightWing <= 0; if (wingsGone || speed < 5) { player.damage.fuselage = 0; player.damage.engine = 0; } } } }; window.addEventListener("keydown", handleGlobalKeyDown); return () => window.removeEventListener("keydown", handleGlobalKeyDown); }, [isPlaying, showTacticalMap, togglePause]); useEffect(() => { if (hitmarker.active) { const timer = setTimeout(() => setHitmarker(prev => ({ ...prev, active: false })), 180); return () => clearTimeout(timer); } }, [hitmarker.key, hitmarker.active]); const saveProgressionAndSyncEngine = useCallback((updated: UserProgression) => { saveProgression(updated); if (activeEngineRef.current) { const player = activeEngineRef.current.pilots.find(p => p.id === "player"); if (player) { player.invertMouseY = updated.invertMouseY ?? false; player.invertMouseX = updated.invertMouseX ?? false; if (updated.controlMode) player.controlMode = updated.controlMode; } } }, [saveProgression]); const handleMatchCompletion = useCallback(( victory: boolean, xpEarned: number, engine: MatchSimulation, _renderer3D: WorldRenderer ) => { const player = engine.pilots.find(p => p.id === "player"); const kills = player ? player.kills : 0; const structures = engine.targetsDestroyedThisMatch; if (victory) { beep(523.25, "sine", 0.15); setTimeout(() => beep(659.25, "sine", 0.15), 150); setTimeout(() => beep(783.99, "sine", 0.25), 300); } else { beep(220, "triangle", 0.3); } setDebriefData({ victory, xpEarned, kills, structures, missionName: engine.campaignMission?.operation }); setShowDebrief(true); setShowTacticalMap(false); setIsPlaying(false); setActiveEngine(null); const updatedStats = { battlesPlayed: progression.stats.battlesPlayed + 1, kills: progression.stats.kills + kills, deaths: progression.stats.deaths + (player ? player.deaths : 0), groundTargetsDestroyed: progression.stats.groundTargetsDestroyed + structures, victories: progression.stats.victories + (victory ? 1 : 0) }; const updated: UserProgression = { ...progression, totalXp: progression.totalXp + xpEarned, completedCampaignMissions: victory && engine.campaignMission ? Array.from(new Set([...(progression.completedCampaignMissions ?? []), engine.campaignMission.id])) : progression.completedCampaignMissions, stats: updatedStats }; saveProgressionAndSyncEngine(updated); if (canvasContainerRef.current && (canvasContainerRef as any).current.cleanupHandler) { (canvasContainerRef as any).current.cleanupHandler(); (canvasContainerRef as any).current.cleanupHandler = null; } }, [progression, saveProgressionAndSyncEngine]); const handleEject = useCallback(() => { if (activeEngine) { if (canvasContainerRef.current && (canvasContainerRef as any).current.cleanupHandler) { (canvasContainerRef as any).current.cleanupHandler(); (canvasContainerRef as any).current.cleanupHandler = null; } setActiveEngine(null); setIsPlaying(false); setShowTacticalMap(false); setIsPaused(false); isPausedRef.current = false; } }, [activeEngine]); const initThreeAndGame = useCallback(async ( planeId: string, belt: AmmoBelt, mods: string[], mapId: string, mode: MatchMode, isMultiplayer: boolean, startOnGround: boolean = false, campaignMissionId?: string ) => { if (!canvasContainerRef.current) return; const mapDef = MAP_REGISTRY[mapId] ?? MAP_REGISTRY[KnownMaps.IslandChain]; if (mapDef.terrain.kind === "heightmap") { try { await loadHeightmap(mapDef.terrain.path, mapDef.world.radius, mapDef.terrain.elevationScale); } catch (e) { console.error("Failed to pre-load heightmap:", e); } } const renderer3D = new WorldRenderer(canvasContainerRef.current, mapDef, () => { console.log("WebGL World initialized successfully."); }); renderer3DRef.current = renderer3D; renderer3D.setCameraMode(cameraModeRef.current, "player"); const engine = new MatchSimulation( planeId, belt, mods, mapId, mode, (killEvt) => { if (killEvt.killerName.includes("You")) { beep(440, "triangle", 0.08); const score = killEvt.method === "Heavy Ordnance" ? 200 : 300; const key = ++killPopupKeyRef.current; setKillPopups(prev => [...prev, { key, value: score }]); setTimeout(() => setKillPopups(prev => prev.filter(p => p.key !== key)), 1600); } }, (victory, xpEarned) => handleMatchCompletion(victory, xpEarned, engine, renderer3D), progression.nickname, startOnGround, campaignMissionId ); engine.onLocalPlayerHit = (tgtId, isGround) => { if (isGround) beep(260, "triangle", 0.045); else beep(680, "sine", 0.04); setHitmarker({ active: true, type: isGround ? "ground" : "air", key: Math.random() }); }; engine.onProjectileImpact = (type, position) => { const size = type === WeaponType.BOMB ? 2.8 : type === WeaponType.ROCKET ? 1.5 : 0.55; renderer3D.triggerExplosion(position.x, position.y, position.z, size); }; const player = engine.pilots.find(p => p.id === "player"); if (player) { player.controlMode = progression.controlMode || ControlMode.MouseAim; player.invertMouseY = progression.invertMouseY ?? false; player.invertMouseX = progression.invertMouseX ?? false; } setActiveEngine(engine); activeEngineRef.current = engine; const inputManager = new InputManager(); inputManagerRef.current = inputManager; inputManager.onCameraToggle = toggleCameraMode; inputManager.onBombSightToggle = toggleBombSight; inputManager.onTacticalMapToggle = () => { if (!isPausedRef.current) setShowTacticalMap(current => !current); }; inputManager.init(); if (isMultiplayer) { multiplayer.connectMultiplayer( engine, renderer3D, mapId, mode, progression.nickname ?? "PILOT", progression.customizations.skin ?? "default", (tgtId: string, isGround: boolean) => { if (isGround) beep(260, "triangle", 0.045); else beep(680, "sine", 0.04); setHitmarker({ active: true, type: isGround ? "ground" : "air", key: Math.random() }); }, (reason: string) => { if (reason === "duplicate_session") window.alert("This pilot is already active in another multiplayer match."); multiplayer.disconnectMultiplayer(); setActiveEngine(null); setIsPlaying(false); } ); } engine.onVoxelHit = (targetId, localOffsetMeters, blastMeters) => renderer3D.deformAircraft(targetId, localOffsetMeters, blastMeters); engine.onPilotRespawn = (pilotId) => renderer3D.resetVoxelState(pilotId); engine.getVoxelImpact = (targetId, segStartLocal, segEndLocal) => renderer3D.findVoxelImpact(targetId, segStartLocal, segEndLocal); let lastTime = performance.now(); let animId = 0; const playerTargetPoint = new Vector3(); const activeAimPos = { x: 0, y: 0 }; let activeAimPosInitialized = false; let playerWasDead = false; let lastHudSyncTime = 0; let fpsWindowStart = performance.now(); let fpsFrameCount = 0; let fpsElement: HTMLElement | null = null; let telemPending: any[] = []; const telemWsProto = location.protocol === "https:" ? "wss://" : "ws://"; const telemWs = new WebSocket(`${telemWsProto}${location.host}/telemetry-ws`); const roundTelemetry = (value: number, decimals: number) => { const scale = 10 ** decimals; return Math.round(value * scale) / scale; }; const flushTelemetry = () => { if (telemPending.length === 0 || telemWs.readyState !== WebSocket.OPEN) return; telemWs.send(JSON.stringify(telemPending)); telemPending = []; }; const telemFlushInterval = setInterval(flushTelemetry, 100); const _netQ = new Quaternion(); const _netQSnap = new Quaternion(); // Scratch objects for the hot loop — never allocate inside loop(). const _loopPos = new Vector3(); const _loopRot = new Quaternion(); const _loopFwd = new Vector3(); const _loopUp = new Vector3(); const _loopRight = new Vector3(); let clientTickSeq = 0; let clientAccumulator = 0; const CLIENT_TICK_RATE = 1 / 60; const pendingInputs: { seq: number; command: any }[] = []; const predictionHistory = new Map(); const loop = (now: number) => { const dt = Math.min(0.08, (now - lastTime) / 1000); lastTime = now; const inputFrame = inputManager.getInputFrame(); const localPlayer = engine.pilots.find(p => p.id === "player"); if (localPlayer && localPlayer.damage.fuselage > 0) { const pos = _loopPos.set(localPlayer.x, localPlayer.y, localPlayer.z); const rot = _loopRot.set(localPlayer.qx, localPlayer.qy, localPlayer.qz, localPlayer.qw); const forward = _loopFwd.set(0, 0, 1).applyQuaternion(rot).normalize(); const up = _loopUp.set(0, 1, 0).applyQuaternion(rot).normalize(); const right = _loopRight.set(1, 0, 0).applyQuaternion(rot).normalize(); const pitchMultiplier = progression.invertMouseY ? -1 : 1; const rollMultiplier = progression.invertMouseX ? -1 : 1; if (!activeAimPosInitialized) { activeAimPos.x = inputFrame.mousePos.x; activeAimPos.y = inputFrame.mousePos.y; activeAimPosInitialized = true; } else if (!inputFrame.rightMouse) { activeAimPos.x += (inputFrame.mousePos.x - activeAimPos.x) * Math.min(1.0, dt * 10); activeAimPos.y += (inputFrame.mousePos.y - activeAimPos.y) * Math.min(1.0, dt * 10); } playerTargetPoint .copy(pos) .addScaledVector(forward, 240) .addScaledVector(right, activeAimPos.x * 125 * rollMultiplier) .addScaledVector(up, activeAimPos.y * 95 * pitchMultiplier); } if (!isPausedRef.current) { engine.secondaryWeaponPreference = cameraModeRef.current === "bombsight" ? WeaponType.BOMB : null; engine.update(dt, inputFrame, playerTargetPoint); } const playerIsDead = !localPlayer || localPlayer.damage.fuselage <= 0; if (playerWasDead && localPlayer && !playerIsDead) { activeAimPos.x = 0; activeAimPos.y = 0; activeAimPosInitialized = true; inputManager.recenterAim(); playerTargetPoint.set(localPlayer.x, localPlayer.y, localPlayer.z); } playerWasDead = playerIsDead; // 1. Client-Side Prediction (Reconciliation) if (isMultiplayer && localPlayer && localPlayer.damage.fuselage > 0) { const serverPos = (localPlayer as any).serverPosition; if (serverPos) { const ackSeq = (localPlayer as any).serverLastProcessedSeq; while (pendingInputs.length > 0 && pendingInputs[0].seq <= ackSeq) { pendingInputs.shift(); } const ackState = predictionHistory.get(ackSeq); if (ackState) { const dist = serverPos.distanceTo(ackState.position); if (dist > 2.5) { // Physics jitter fix threshold localPlayer.x = serverPos.x; localPlayer.y = serverPos.y; localPlayer.z = serverPos.z; localPlayer.vx = (localPlayer as any).serverVelocity.x; localPlayer.vy = (localPlayer as any).serverVelocity.y; localPlayer.vz = (localPlayer as any).serverVelocity.z; pendingInputs.forEach((inp) => FlightPhysicsEngine.update(localPlayer, inp.command, CLIENT_TICK_RATE, mapId)); } } for (const key of predictionHistory.keys()) { if (key < ackSeq) predictionHistory.delete(key); } (localPlayer as any).serverPosition = null; } // Fixed tick local loop clientAccumulator += dt; if (clientAccumulator > 0.1) clientAccumulator = 0.1; while (clientAccumulator >= CLIENT_TICK_RATE) { clientTickSeq++; const controller = engine.getOrCreateController(localPlayer.id); const command = controller.update(localPlayer, inputFrame, playerTargetPoint, CLIENT_TICK_RATE); localPlayer.lastCommand = command; pendingInputs.push({ seq: clientTickSeq, command }); FlightPhysicsEngine.update(localPlayer, command, CLIENT_TICK_RATE, mapId); engine.enforceMapBoundary(localPlayer, CLIENT_TICK_RATE); predictionHistory.set(clientTickSeq, { position: new Vector3(localPlayer.x, localPlayer.y, localPlayer.z), velocity: new Vector3(localPlayer.vx, localPlayer.vy, localPlayer.vz), qx: localPlayer.qx, qy: localPlayer.qy, qz: localPlayer.qz, qw: localPlayer.qw }); if (multiplayer.roomRef.current) { multiplayer.roomRef.current.send("input", [ clientTickSeq, command.pitch, command.roll, command.yaw, command.throttleDelta, command.boost ? 1 : 0, command.airbrake ? 1 : 0, command.primaryFire ? 1 : 0, command.secondaryFire ? 1 : 0, command.flaps === "landing" ? 2 : command.flaps === "combat" ? 1 : 0, command.gearDeployed ? 1 : 0 ]); } clientAccumulator -= CLIENT_TICK_RATE; } } if (now - lastHudSyncTime >= 80) { lastHudSyncTime = now; setHudSnapshot({ pilots: [...engine.pilots], groundTargets: [...engine.groundTargets], zones: [...engine.skyZones], killFeed: [...engine.killFeed], team1Score: engine.team1Score, team2Score: engine.team2Score, matchTimer: engine.matchTimer, bombSightInfo: renderer3D.bombSightInfo, campaignState: engine.campaignState ? { ...engine.campaignState } : null }); } if (isMultiplayer) { const snapNow = performance.now(); const alpha = 1 - Math.exp(-dt * 25); for (const pilot of engine.pilots) { if (pilot.id === "player" || !pilot.netSnap) continue; const snap = pilot.netSnap; const age = (snapNow - snap.at) / 1000; pilot.x += (snap.x + snap.vx * age - pilot.x) * alpha; pilot.y += (snap.y + snap.vy * age - pilot.y) * alpha; pilot.z += (snap.z + snap.vz * age - pilot.z) * alpha; _netQSnap.set(snap.qx, snap.qy, snap.qz, snap.qw); _netQ.set(pilot.qx, pilot.qy, pilot.qz, pilot.qw); _netQ.slerp(_netQSnap, alpha); pilot.qx = _netQ.x; pilot.qy = _netQ.y; pilot.qz = _netQ.z; pilot.qw = _netQ.w; } } renderer3D.updateWorld(engine.pilots, "player", engine.projectiles, engine.groundTargets, playerTargetPoint, engine.skyZones, mode, inputFrame, dt); fpsFrameCount++; const fpsElapsed = now - fpsWindowStart; if (fpsElapsed >= 500) { fpsElement ??= document.getElementById("hud-fps-counter"); if (fpsElement) { const fps = (fpsFrameCount * 1000) / fpsElapsed; const frameMs = fps > 0 ? 1000 / fps : 0; const { drawCalls, triangles } = renderer3D.getRenderStats(); fpsElement.textContent = `${Math.round(fps)} FPS · ${frameMs.toFixed(1)} MS · ${drawCalls} DC · ${(triangles / 1000).toFixed(1)}K TRI`; fpsElement.dataset.level = fps >= 55 ? "good" : fps >= 35 ? "warn" : "bad"; } fpsWindowStart = now; fpsFrameCount = 0; } if (localPlayer?.physicsDebug) { const d = localPlayer.physicsDebug; const spd = Math.sqrt(localPlayer.vx ** 2 + localPlayer.vy ** 2 + localPlayer.vz ** 2) * 3.6; const manualYaw = (inputFrame.held.arrowRight ? 1 : 0) - (inputFrame.held.arrowLeft ? 1 : 0) + (inputFrame.held.e ? 0.65 : 0) - (inputFrame.held.q ? 0.65 : 0); telemPending.push({ t: roundTelemetry(localPlayer.physicsTime ?? 0, 3), spd: roundTelemetry(spd, 1), alt: roundTelemetry(localPlayer.y, 1), thr: roundTelemetry(localPlayer.throttle, 2), aoa: roundTelemetry(d.aoaDeg, 2), ss: roundTelemetry(d.sideslipDeg, 2), cm: localPlayer.controlMode === ControlMode.MouseJoystick ? 1 : localPlayer.controlMode === ControlMode.KeyboardDirect ? 2 : 0, cp: roundTelemetry(localPlayer.lastCommand?.pitch ?? 0, 3), cr: roundTelemetry(localPlayer.lastCommand?.roll ?? 0, 3), cy: roundTelemetry(localPlayer.lastCommand?.yaw ?? 0, 3), myw: roundTelemetry(manualYaw, 2), sev: roundTelemetry(d.stallSeverity, 3) }); } inputManager.clearPressedEdges(); animId = requestAnimationFrame(loop); }; animId = requestAnimationFrame(loop); (canvasContainerRef as any).current.cleanupHandler = () => { cancelAnimationFrame(animId); clearInterval(telemFlushInterval); flushTelemetry(); telemWs.close(); inputManager.destroy(); inputManagerRef.current = null; multiplayer.disconnectMultiplayer(); renderer3D.destroy(); if (renderer3DRef.current === renderer3D) renderer3DRef.current = null; }; }, [cameraModeRef, handleMatchCompletion, multiplayer, progression, toggleBombSight, toggleCameraMode]); const launchMatch = useCallback(( selectedPlaneId: string, belt: AmmoBelt, mods: string[], mapId: string, mode: MatchMode, isMultiplayer: boolean, startOnGround: boolean = false, campaignMissionId?: string ) => { setActiveCameraMode("third-person"); setShowTacticalMap(false); setIsPlaying(true); setShowDebrief(false); setActiveMatchMode(mode); setIsPaused(false); isPausedRef.current = false; setTimeout(() => { initThreeAndGame(selectedPlaneId, belt, mods, mapId, mode, isMultiplayer, startOnGround, campaignMissionId); }, 150); }, [initThreeAndGame, setActiveCameraMode]); return { isPlaying, showDebrief, debriefData, cameraMode, hudSnapshot, activeMatchMode, hitmarker, killPopups, isPaused, showTacticalMap, activeEngine, canvasContainerRef, inputManagerRef, launchMatch, handleEject, togglePause, setShowDebrief, setDebriefData, setShowTacticalMap, saveProgressionAndSyncEngine }; }