airframe.io / src /hooks /useGameEngine.ts
LATTICEWORKS's picture
eliminate per-frame allocations and fix framerate-dependent smoothing
3386355
Raw
History Blame Contribute Delete
24 kB
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<any>;
}
) {
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<HTMLDivElement>(null);
const renderer3DRef = useRef<WorldRenderer | null>(null);
const inputManagerRef = useRef<InputManager | null>(null);
const activeEngineRef = useRef<MatchSimulation | null>(null);
const [activeEngine, setActiveEngine] = useState<MatchSimulation | null>(null);
const [cameraMode, setCameraMode] = useState<CameraMode>("third-person");
const cameraModeRef = useRef<CameraMode>("third-person");
const [hudSnapshot, setHudSnapshot] = useState<HudSnapshot>({
pilots: [], groundTargets: [], zones: [], killFeed: [],
team1Score: 0, team2Score: 0, matchTimer: 360,
bombSightInfo: null, campaignState: null
});
const [activeMatchMode, setActiveMatchMode] = useState<MatchMode>(MatchMode.AirSupremacy);
const [hitmarker, setHitmarker] = useState<{ active: boolean; type: "air" | "ground"; key: number }>({
active: false, type: "air", key: 0
});
const [killPopups, setKillPopups] = useState<Array<{ key: number; value: number }>>([]);
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<number, { position: Vector3; velocity: Vector3; qx: number; qy: number; qz: number; qw: number }>();
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
};
}