airframe.io / src /game /matchSimulation.ts
LATTICEWORKS's picture
eliminate per-frame allocations and fix framerate-dependent smoothing
3386355
Raw
History Blame Contribute Delete
31.3 kB
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import * as THREE from "three";
import { Vector3, Quaternion, Euler } from "three";
import {
Pilot,
Projectile,
GroundTarget,
SkyZone,
InputFrame,
AircraftSpecs,
MatchMode,
WeaponType,
AmmoBelt,
KillEvent,
CampaignMissionDefinition,
CampaignMissionState
} from "../types";
import { DEFAULT_AIRCRAFT, WEAPON_SPECS_MAP } from "./aircraftData";
import { FlightPhysicsEngine } from "./flightModel";
import { generateId } from "./math";
import { AircraftController } from "./aircraftController";
import { ProjectileSystem } from "./projectileSystem";
import { BotAISystem } from "./botAISystem";
import { GroundDefenseSystem } from "./groundDefenseSystem";
import { ObjectiveSystem } from "./objectiveSystem";
import { getTerrainHeight, getTerrainLayout } from "./terrainModel";
import { MAP_REGISTRY } from "./content/maps/registry";
import { resolveCarriers } from "./content/structures/registry";
import { getCampaignMission } from "./content/campaign/campaignMissions";
const BOT_NAMES = [
"RedBaron_00",
"VipersNest",
"SkyMaverick",
"GhostPilot",
"Spitfire99",
"ZeroG",
"Slayer_Ace",
"MustangSally",
"TornadoSlam",
"Flanker_Fritz",
"ZephyrDancer",
"CloudSweeper"
];
type EnginePilot = Pilot & {
weaponCooldowns?: Partial<Record<WeaponType, number>>;
invulnerableTimer?: number;
};
function getSpawnYaw(team: 1 | 2): number {
return team === 1 ? Math.PI / 2 : -Math.PI / 2;
}
function createEmptyInputFrame(): InputFrame {
return {
held: {
w: false,
s: false,
a: false,
d: false,
q: false,
e: false,
b: false,
f: false,
g: false,
shift: false,
control: false,
space: false,
r: false,
arrowUp: false,
arrowDown: false,
arrowLeft: false,
arrowRight: false
},
edges: {
flapsPressed: false,
gearPressed: false,
cameraPressed: false,
resetPressed: false
},
mousePos: { x: 0, y: 0 },
mouseDelta: { x: 0, y: 0 },
rightMouse: false
};
}
export class MatchSimulation {
public pilots: Pilot[] = [];
public projectiles: Projectile[] = [];
public groundTargets: GroundTarget[] = [];
public skyZones: SkyZone[] = [];
public killFeed: KillEvent[] = [];
public playerPilotId!: string;
public selectedMapId: string;
public matchMode: MatchMode;
public team1Score = 0;
public team2Score = 0;
public matchTimer = 360;
public matchEnded = false;
public xpEarnedThisMatch = 0;
public targetsDestroyedThisMatch = 0;
public campaignMission: CampaignMissionDefinition | null = null;
public campaignState: CampaignMissionState | null = null;
public secondaryWeaponPreference: WeaponType | null = null;
public isMultiplayer = false;
public isHost = false;
private _physicsAccumulator = 0;
public onProjectileSpawn?: (type: WeaponType) => void;
public onProjectileImpact?: (
type: WeaponType,
position: Vector3,
ownerId: string
) => void;
public onGroundTargetDamage?: (targetId: string, hp: number, isDead: boolean) => void;
public onLocalPlayerKill?: (killerId: string, victimId: string, weapon: string) => void;
public onLocalPlayerHit?: (targetId: string, isGround: boolean) => void;
public onPlayerDamage?: (
shooterId: string,
targetId: string,
damage: number,
bulletType: string,
hitSpotLocal: Vector3
) => void;
public onVoxelHit?: (
targetId: string,
localOffsetMeters: Vector3,
blastMeters: number
) => void;
public onPilotRespawn?: (pilotId: string) => void;
public getVoxelImpact?: (
targetId: string,
segStartLocal: Vector3,
segEndLocal: Vector3
) => THREE.Vector3 | null | undefined;
private onKillCallback: (event: KillEvent) => void;
private onGameOverCallback: (victory: boolean, xp: number) => void;
public onMatchEnd?: (playerWon: boolean) => void;
private controllers = new Map<string, AircraftController>();
public getOrCreateController(pilotId: string): AircraftController {
let ctrl = this.controllers.get(pilotId);
if (!ctrl) {
ctrl = new AircraftController();
this.controllers.set(pilotId, ctrl);
}
return ctrl;
}
constructor(
playerPlaneId: string,
playerBelt: AmmoBelt,
playerMods: string[],
mapId: string,
mode: MatchMode,
onKill: (event: KillEvent) => void,
onGameOver: (victory: boolean, xp: number) => void,
playerNickname?: string,
startOnGround: boolean = false,
campaignMissionId?: string | null
) {
this.selectedMapId = mapId;
this.matchMode = mode;
this.onKillCallback = onKill;
this.onGameOverCallback = onGameOver;
this.campaignMission = getCampaignMission(campaignMissionId);
if (this.campaignMission) {
this.matchTimer = this.campaignMission.timeLimitSeconds;
this.campaignState = {
missionId: this.campaignMission.id,
name: this.campaignMission.operation,
objectiveLabel: this.campaignMission.objectiveLabel,
progress: 0,
targetCount: this.campaignMission.targetCount,
completed: false
};
}
this.initMatch(playerPlaneId, playerBelt, playerMods, playerNickname, startOnGround);
}
private initMatch(
playerPlaneId: string,
playerBelt: AmmoBelt,
playerMods: string[],
playerNickname?: string,
startOnGround: boolean = false
) {
const pSpecs =
DEFAULT_AIRCRAFT.find(a => a.id === playerPlaneId) || DEFAULT_AIRCRAFT[0];
// Calculate starting position coordinates depending on spawn ground preference
const { x: airX, y: airY, z: airZ, vx: airVx, vy: airVy, vz: airVz } = this.getAirSpawnPosition(1);
let initialX = airX;
let initialY = airY;
let initialZ = airZ;
let initialVx = airVx;
let initialVy = airVy;
let initialVz = airVz;
let initialYaw = getSpawnYaw(1);
let initialThrottle = 0.8;
let initialGearDeployed = false;
let initialFlaps: "up" | "combat" | "landing" = "up";
if (startOnGround) {
const mapDef = MAP_REGISTRY[this.selectedMapId];
const teamCarrier = mapDef ? resolveCarriers(mapDef.layout.carriers)[0] : undefined;
if (teamCarrier) {
const yaw = teamCarrier.rotationY;
initialX = teamCarrier.x - Math.sin(yaw) * 120;
initialZ = teamCarrier.z - Math.cos(yaw) * 120;
initialY = teamCarrier.deckHeight;
initialVx = 0;
initialVy = 0;
initialVz = 0;
initialYaw = yaw;
initialThrottle = 0.0; // Engine idling, ready for launch throttle-up
initialGearDeployed = true; // Landing gear down
initialFlaps = "combat"; // Takeoff/combat flaps for extra low-speed lift assist
} else {
const layout = mapDef ? getTerrainLayout(mapDef) : null;
const airfieldFeature = layout?.features.find(f => f.type === "airfield");
const airfieldBlock = airfieldFeature
? layout!.blocks.find(b => b.id === airfieldFeature.parentBlockId)
: null;
if (airfieldBlock) {
const yaw = airfieldBlock.rotationY;
initialX = airfieldBlock.position[0] - Math.sin(yaw) * 300;
initialZ = airfieldBlock.position[2] - Math.cos(yaw) * 300;
initialY = airfieldBlock.scale[1] + 10.2;
initialVx = 0;
initialVy = 0;
initialVz = 0;
initialYaw = yaw;
initialThrottle = 0.0;
initialGearDeployed = true;
initialFlaps = "combat";
} else {
// Fallback to ground level spawn
initialX = -1500;
initialZ = 50;
const terr = getTerrainHeight(initialX, initialZ, this.selectedMapId);
initialY = terr.height + 1.5;
initialVx = 0;
initialVy = 0;
initialVz = 0;
initialYaw = getSpawnYaw(1);
initialThrottle = 0.0;
initialGearDeployed = true;
initialFlaps = "combat";
}
}
}
const playerPilot: EnginePilot = new Pilot({
id: "player",
name: playerNickname ? `${playerNickname} (You)` : "You (Pilot-01)",
isBot: false,
team: 1,
aircraftId: pSpecs.id,
specs: pSpecs,
x: initialX,
y: initialY,
z: initialZ,
vx: initialVx,
vy: initialVy,
vz: initialVz,
qx: 0,
qy: Math.sin(initialYaw / 2),
qz: 0,
qw: Math.cos(initialYaw / 2),
throttle: initialThrottle,
engineTemperature: 75,
damage: this.createEmptyDamage(),
ammo: this.initAmmo(pSpecs),
ammoBelt: playerBelt,
modifications: playerMods,
score: 0,
kills: 0,
deaths: 0,
xpEarned: 0,
smoothedPitch: 0,
smoothedRoll: 0,
smoothedYaw: 0,
elevatorDeflection: 0,
aileronDeflection: 0,
rudderDeflection: 0,
flaps: initialFlaps,
gearDeployed: initialGearDeployed,
airbrakeDeployed: false,
weaponCooldowns: {},
invulnerableTimer: 1.5
}) as EnginePilot;
this.pilots.push(playerPilot);
this.playerPilotId = "player";
if (this.matchMode === MatchMode.DuelArena) {
this.spawnBot(2, DEFAULT_AIRCRAFT[Math.floor(Math.random() * DEFAULT_AIRCRAFT.length)]);
this.matchTimer = 180;
} else {
// Fill the room with bots — real players displace them as they join.
// ROOM_SIZE must match MAX_PLAYERS_PER_ROOM in server.ts.
const ROOM_SIZE = 32;
const half = ROOM_SIZE / 2;
for (let i = 0; i < half - 1; i++) {
this.spawnBot(1, DEFAULT_AIRCRAFT[Math.floor(Math.random() * DEFAULT_AIRCRAFT.length)]);
}
for (let i = 0; i < half; i++) {
this.spawnBot(2, DEFAULT_AIRCRAFT[Math.floor(Math.random() * DEFAULT_AIRCRAFT.length)]);
}
}
this.buildObjectives();
}
// Called by the host client when a real player joins — evict one bot from that team.
public removeBot(team: 1 | 2): boolean {
const idx = this.pilots.findIndex(p => p.isBot && p.team === team);
if (idx === -1) return false;
this.pilots.splice(idx, 1);
return true;
}
// Called by the host client when a real player leaves — fill the empty slot.
public addBot(team: 1 | 2) {
const specs = DEFAULT_AIRCRAFT[Math.floor(Math.random() * DEFAULT_AIRCRAFT.length)];
this.spawnBot(team, specs);
}
private createEmptyDamage() {
return {
engine: 1.0,
leftWing: 1.0,
rightWing: 1.0,
tail: 1.0,
cockpit: 1.0,
fuelTank: 1.0,
fuselage: 1.0,
hasFire: false,
hasOilLeak: false
};
}
private initAmmo(specs: AircraftSpecs): Record<WeaponType, number> {
const caps: Record<WeaponType, number> = {} as any;
specs.weapons.forEach(w => {
caps[w] = WEAPON_SPECS_MAP[w].ammoCapacity;
});
return caps;
}
private getAirSpawnPosition(team: 1 | 2): { x: number; y: number; z: number; vx: number; vy: number; vz: number } {
const mapDef = MAP_REGISTRY[this.selectedMapId];
const sp = mapDef?.spawn ?? { distMin: 3500, distMax: 4200, aglMin: 350, aglMax: 650, initialSpeedMs: 140, spreadZ: 600 };
const dist = sp.distMin + Math.random() * (sp.distMax - sp.distMin);
const sign = team === 1 ? -1 : 1;
const x = sign * dist;
const z = (Math.random() - 0.5) * 2 * sp.spreadZ;
const terrain = getTerrainHeight(x, z, this.selectedMapId);
const agl = sp.aglMin + Math.random() * (sp.aglMax - sp.aglMin);
const y = terrain.height + agl;
// Derive velocity from the spawn yaw quaternion so world-space direction
// is always consistent with aircraft orientation — no manual sign assumptions.
const yaw = getSpawnYaw(team);
const q = new Quaternion().setFromEuler(new Euler(0, yaw, 0, "YXZ"));
const vel = new Vector3(0, 0, 1).applyQuaternion(q).multiplyScalar(sp.initialSpeedMs);
return { x, y, z, vx: vel.x, vy: vel.y, vz: vel.z };
}
private spawnBot(team: 1 | 2, specs: AircraftSpecs, nameOverride?: string) {
const bId = generateId();
const { x, y, z, vx, vy, vz } = this.getAirSpawnPosition(team);
const pilot: EnginePilot = new Pilot({
id: bId,
name: nameOverride || BOT_NAMES[Math.floor(Math.random() * BOT_NAMES.length)],
isBot: true,
team,
aircraftId: specs.id,
specs,
x,
y,
z,
vx,
vy,
vz,
qx: 0,
qy: Math.sin(getSpawnYaw(team) / 2),
qz: 0,
qw: Math.cos(getSpawnYaw(team) / 2),
throttle: 0.75,
engineTemperature: 72,
damage: this.createEmptyDamage(),
ammo: this.initAmmo(specs),
ammoBelt: AmmoBelt.Universal,
modifications: [],
score: 0,
kills: 0,
deaths: 0,
xpEarned: 0,
smoothedPitch: 0,
smoothedRoll: 0,
smoothedYaw: 0,
elevatorDeflection: 0,
aileronDeflection: 0,
rudderDeflection: 0,
flaps: "up",
gearDeployed: false,
airbrakeDeployed: false,
weaponCooldowns: {},
invulnerableTimer: 1.5,
aiState: {
behavior: "patrol",
targetId: null,
timer: Math.random() * 3,
destinationX: 0,
destinationY: 450,
destinationZ: 0,
skills: {
accuracy: 0.5 + Math.random() * 0.4,
aggression: 0.4 + Math.random() * 0.5,
avoidance: 0.3 + Math.random() * 0.6
}
}
}) as EnginePilot;
this.pilots.push(pilot);
}
private buildObjectives() {
this.groundTargets = [];
this.skyZones = [];
if (
this.matchMode === MatchMode.AirSupremacy ||
this.matchMode === MatchMode.EndlessFront
) {
this.skyZones.push(
{
id: "zone-a",
name: "Alpha Zone",
x: -1200,
y: 500,
z: -500,
radius: 450,
owningTeam: 0,
captureProgress: 0
},
{
id: "zone-b",
name: "Bravo Zone",
x: 0,
y: 700,
z: 0,
radius: 600,
owningTeam: 0,
captureProgress: 0
},
{
id: "zone-c",
name: "Charlie Zone",
x: 1200,
y: 500,
z: 500,
radius: 450,
owningTeam: 0,
captureProgress: 0
}
);
}
const islandLocations = [
{ x: -2000, z: -1000 },
{ x: -500, z: -2500 },
{ x: 1800, z: -800 },
{ x: 300, z: 1600 },
{ x: -1100, z: 2400 },
{ x: 2200, z: 2000 }
];
islandLocations.forEach((loc, index) => {
const assignedTeam = index % 2 === 0 ? 1 : 2;
const aaY = getTerrainHeight(loc.x, loc.z, this.selectedMapId).height;
const radarX = loc.x + 80;
const radarZ = loc.z + 80;
const radarY = getTerrainHeight(radarX, radarZ, this.selectedMapId).height;
this.groundTargets.push(new GroundTarget({
id: `aa-${index}`,
name: `FlaK AA Battery ${assignedTeam === 1 ? "A" : "B"}`,
team: assignedTeam as 1 | 2,
type: "anti-air",
x: loc.x,
y: aaY,
z: loc.z,
hp: 120,
maxHp: 120,
isDead: false,
fireCooldown: Math.random() * 2
}));
this.groundTargets.push(new GroundTarget({
id: `tgt-${index}`,
name: assignedTeam === 1 ? "Red Radar Station" : "Blue Radar Station",
team: assignedTeam as 1 | 2,
type: "radar",
x: radarX,
y: radarY,
z: radarZ,
hp: 180,
maxHp: 180,
isDead: false
}));
});
if (this.matchMode === MatchMode.GroundStrike) {
for (const team of [1, 2] as const) {
for (let i = 0; i < 4; i++) {
const cx = team === 1 ? -3000 + i * 150 : 3000 - i * 150;
const cz = team === 1 ? -1500 + i * 40 : 1500 - i * 40;
this.groundTargets.push(new GroundTarget({
id: `convoy-t${team}-${i}`,
name: `${team === 1 ? "Teammate" : "Adversary"} Convoy T${team} - #${i + 1}`,
team,
type: "convoy",
x: cx,
y: getTerrainHeight(cx, cz, this.selectedMapId).height,
z: cz,
hp: 80,
maxHp: 80,
isDead: false
}));
}
}
}
}
public update(dt: number, inputFrame: InputFrame, playerMouseTarget: Vector3 | null) {
if (this.matchEnded) return;
this.matchTimer = Math.max(0, this.matchTimer - dt);
if (this.matchTimer <= 0) {
this.endGame();
return;
}
// Fixed 60 Hz physics step. The accumulator carries the sub-step residual
// forward rather than integrating it immediately, which prevents the
// alternating 1-step/2-step pattern that causes visual jitter at 60 fps.
const FIXED_STEP = 1 / 60;
this._physicsAccumulator = Math.min(this._physicsAccumulator + dt, 0.1);
while (this._physicsAccumulator >= FIXED_STEP) {
const step = FIXED_STEP;
this._physicsAccumulator -= FIXED_STEP;
this.tickPilotCooldowns(step);
const player = this.pilots.find(p => p.id === "player") as EnginePilot | undefined;
if (player) {
if (player.damage.fuselage <= 0) {
this.updateDeadPilot(player, step);
} else if (!this.isMultiplayer) {
const controller = this.getOrCreateController(player.id);
const command = controller.update(player, inputFrame, playerMouseTarget, step);
player.lastCommand = command;
FlightPhysicsEngine.update(player, command, step, this.selectedMapId);
this.enforceMapBoundary(player, step);
this.handleWeaponFiring(player, command.primaryFire, command.secondaryFire, step);
}
}
this.pilots.forEach(p => {
if (p.id === "player") return;
const pilot = p as EnginePilot;
if (pilot.damage.fuselage <= 0) {
this.updateDeadPilot(pilot, step);
} else {
if (pilot.isBot) {
if (this.isMultiplayer && !this.isHost) {
// In multiplayer, remote non-host clients let the host synchronize bot positions
pilot.x += pilot.vx * step;
pilot.y += pilot.vy * step;
pilot.z += pilot.vz * step;
return;
}
this.runAIConsensus(pilot, step);
const botInput = createEmptyInputFrame();
const botTarget = pilot.aiState
? new Vector3(
pilot.aiState.destinationX,
pilot.aiState.destinationY,
pilot.aiState.destinationZ
)
: null;
const controller = this.getOrCreateController(pilot.id);
const command = controller.update(pilot, botInput, botTarget, step);
pilot.lastCommand = command;
FlightPhysicsEngine.update(pilot, command, step, this.selectedMapId);
this.enforceMapBoundary(pilot, step);
} else {
// Remote player linear extrapolation
pilot.x += pilot.vx * step;
pilot.y += pilot.vy * step;
pilot.z += pilot.vz * step;
}
}
});
if (!this.isMultiplayer) {
// In multiplayer, projectiles, ground defense, capture zones,
// and end-of-match are all server-authoritative. Running them
// locally would score kills against the server-synced pilots
// (which start at origin before their first snapshot) and
// incorrectly trigger endGame on the client.
this.updateProjectiles(step);
this.updateGroundDefense(step);
this.updateCaptureZones(step);
}
}
this.updateCampaignMission();
if (!this.isMultiplayer && !this.campaignMission && (this.team1Score >= 1000 || this.team2Score >= 1000)) {
this.endGame();
}
}
private tickPilotCooldowns(dt: number) {
this.pilots.forEach(p => {
const ep = p as EnginePilot;
ep.invulnerableTimer = Math.max(0, (ep.invulnerableTimer ?? 0) - dt);
if (!ep.weaponCooldowns) ep.weaponCooldowns = {};
Object.keys(ep.weaponCooldowns).forEach(k => {
const w = k as WeaponType;
ep.weaponCooldowns![w] = Math.max(0, (ep.weaponCooldowns![w] ?? 0) - dt);
});
});
}
private updateDeadPilot(pilot: Pilot, dt: number) {
pilot.vy -= 90 * dt;
const q = new Quaternion(pilot.qx, pilot.qy, pilot.qz, pilot.qw);
q.multiply(new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), dt * 4));
pilot.qx = q.x; pilot.qy = q.y; pilot.qz = q.z; pilot.qw = q.w;
pilot.x += pilot.vx * dt;
pilot.y += pilot.vy * dt;
pilot.z += pilot.vz * dt;
if (pilot.y < 20) {
this.respawnPilot(pilot);
}
}
private handleWeaponFiring(
pilot: Pilot,
triggerPrimary: boolean,
triggerSecondary: boolean,
dt: number
) {
const ep = pilot as EnginePilot;
if (!ep.weaponCooldowns) ep.weaponCooldowns = {};
const usableSecondary = pilot.specs.weapons.find(wType => {
if (wType !== WeaponType.ROCKET && wType !== WeaponType.BOMB) return false;
if ((pilot.ammo[wType] ?? 0) <= 0) return false;
return this.secondaryWeaponPreference
? wType === this.secondaryWeaponPreference
: true;
}) ?? pilot.specs.weapons.find(
wType =>
(wType === WeaponType.ROCKET || wType === WeaponType.BOMB) &&
(pilot.ammo[wType] ?? 0) > 0
);
pilot.specs.weapons.forEach(wType => {
const spec = WEAPON_SPECS_MAP[wType];
const ammo = pilot.ammo[wType] ?? 0;
if (ammo <= 0) return;
const cooldown = ep.weaponCooldowns![wType] ?? 0;
if (cooldown > 0) return;
const isSecondary = wType === WeaponType.ROCKET || wType === WeaponType.BOMB;
if (isSecondary) {
if (!triggerSecondary) return;
if (wType !== usableSecondary) return;
this.spawnProjectile(pilot, wType);
pilot.ammo[wType]--;
ep.weaponCooldowns![wType] = 1 / Math.max(0.01, spec.fireRate);
return;
}
if (!triggerPrimary) return;
const shotChance = spec.fireRate * dt * 0.9;
if (Math.random() < shotChance) {
this.spawnProjectile(pilot, wType);
pilot.ammo[wType]--;
ep.weaponCooldowns![wType] = 0.015;
}
});
}
public spawnProjectile(pilot: Pilot, type: WeaponType) {
ProjectileSystem.spawnProjectile(pilot, type, this.projectiles, this.onProjectileSpawn);
}
private updateProjectiles(dt: number) {
ProjectileSystem.updateProjectiles(
dt,
this.projectiles,
this.pilots,
this.groundTargets,
this.selectedMapId,
{
registerKill: (k, v, w) => this.registerKill(k, v, w),
registerGroundTargetKill: (k, t) => this.registerGroundTargetKill(k, t),
onProjectileSpawn: this.onProjectileSpawn,
onProjectileImpact: this.onProjectileImpact,
onGroundTargetDamage: this.onGroundTargetDamage,
onPlayerDamage: (k, t, d, b, s) => {
if (this.onPlayerDamage) {
this.onPlayerDamage(k, t, d, b, s);
}
},
onHitEnemy: (k, t, isGround) => {
if (k === "player" && this.onLocalPlayerHit) {
this.onLocalPlayerHit(t, isGround);
}
},
onVoxelHit: (targetId, localOffsetMeters, blastMeters) => {
if (this.onVoxelHit) this.onVoxelHit(targetId, localOffsetMeters, blastMeters);
},
getVoxelImpact: (targetId, segStartLocal, segEndLocal) => {
return this.getVoxelImpact?.(targetId, segStartLocal, segEndLocal);
}
}
);
}
public forceRegisterKill(killerId: string, victimId: string, weapon: string) {
this.registerKill(killerId, victimId, weapon);
}
private registerKill(killerId: string, victimId: string, weapon: string) {
const killer = this.pilots.find(p => p.id === killerId);
const victim = this.pilots.find(p => p.id === victimId);
if (!victim) return;
if (victim.damage.fuselage <= 0) return; // Guard duplicate registers in multiplayer
if (killerId === "player" && this.onLocalPlayerKill) {
this.onLocalPlayerKill(killerId, victimId, weapon);
}
if (victim.damage.fuselage > 0) {
victim.damage.fuselage = 0;
}
victim.deaths++;
if (killer) {
killer.kills++;
killer.score += 300;
// Non-host clients receive authoritative team scores via score_sync; skip local accumulation
// to avoid double-counting when kill_confirmed arrives out of order relative to scores_updated.
if (!this.isMultiplayer || this.isHost) {
if (killerId === "player") {
this.xpEarnedThisMatch += 150 + (weapon === WeaponType.ROCKET ? 100 : 0);
this.team1Score += 100;
} else if (killer.team === 1) {
this.team1Score += 100;
} else {
this.team2Score += 100;
}
} else if (killerId === "player") {
this.xpEarnedThisMatch += 150 + (weapon === WeaponType.ROCKET ? 100 : 0);
}
}
const event: KillEvent = {
id: generateId(),
killerName: killer ? killer.name : "System",
killerTeam: killer ? killer.team : 1,
victimName: victim.name,
victimTeam: victim.team,
method: weapon,
timestamp: Date.now()
};
this.killFeed.unshift(event);
if (this.killFeed.length > 6) this.killFeed.pop();
this.onKillCallback(event);
}
private registerGroundTargetKill(killerId: string, target: GroundTarget) {
const killer = this.pilots.find(p => p.id === killerId);
if (killer) {
killer.score += 200;
if (killerId === "player") {
this.xpEarnedThisMatch += 120;
this.targetsDestroyedThisMatch++;
this.team1Score += 80;
} else if (killer.team === 1) {
this.team1Score += 80;
} else {
this.team2Score += 80;
}
}
const event: KillEvent = {
id: generateId(),
killerName: killer ? killer.name : "System",
killerTeam: killer ? killer.team : 1,
victimName: target.name,
victimTeam: target.team,
method: "Heavy Ordnance",
timestamp: Date.now()
};
this.killFeed.unshift(event);
if (this.killFeed.length > 6) this.killFeed.pop();
this.onKillCallback(event);
}
public enforceMapBoundary(pilot: Pilot, dt: number) {
const mapRadius = MAP_REGISTRY[this.selectedMapId]?.world?.radius ?? 6000;
const dist = Math.sqrt(pilot.x * pilot.x + pilot.z * pilot.z);
if (dist > mapRadius) {
const horizontalSpeed = Math.hypot(pilot.vx, pilot.vz);
if (horizontalSpeed > 1e-6) {
const targetVx = (-pilot.x / dist) * horizontalSpeed;
const targetVz = (-pilot.z / dist) * horizontalSpeed;
const steer = Math.min(1, dt * 2);
pilot.vx += (targetVx - pilot.vx) * steer;
pilot.vz += (targetVz - pilot.vz) * steer;
// Lerp can slightly shrink a vector but must never add kinetic energy.
const steeredSpeed = Math.hypot(pilot.vx, pilot.vz);
if (steeredSpeed > horizontalSpeed) {
const correction = horizontalSpeed / steeredSpeed;
pilot.vx *= correction;
pilot.vz *= correction;
}
if (pilot.isBot) {
// Snap bot orientation inward to prevent boundary locking
const resetYaw = Math.atan2(-pilot.x, -pilot.z);
pilot.qx = 0;
pilot.qy = Math.sin(resetYaw / 2);
pilot.qz = 0;
pilot.qw = Math.cos(resetYaw / 2);
}
}
}
}
private respawnPilot(pilot: Pilot) {
const ep = pilot as EnginePilot;
pilot.damage = this.createEmptyDamage();
pilot.ammo = this.initAmmo(pilot.specs);
ep.weaponCooldowns = {};
ep.invulnerableTimer = 2.0;
const { x: rx, y: ry, z: rz, vx: rvx, vy: rvy, vz: rvz } = this.getAirSpawnPosition(pilot.team as 1 | 2);
pilot.x = rx;
pilot.y = ry;
pilot.z = rz;
pilot.vx = rvx;
pilot.vy = rvy;
pilot.vz = rvz;
pilot.qx = 0;
pilot.qy = Math.sin(getSpawnYaw(pilot.team) / 2);
pilot.qz = 0;
pilot.qw = Math.cos(getSpawnYaw(pilot.team) / 2);
pilot.avx = 0;
pilot.avy = 0;
pilot.avz = 0;
pilot.isStalling = false;
pilot.stallSeverity = 0;
pilot.physicsDebug = undefined;
pilot.throttle = 0.8;
pilot.engineTemperature = 75;
pilot.smoothedPitch = 0;
pilot.smoothedRoll = 0;
pilot.smoothedYaw = 0;
pilot.elevatorDeflection = 0;
pilot.aileronDeflection = 0;
pilot.rudderDeflection = 0;
pilot.lastCommand = undefined;
pilot.flaps = "up";
pilot.gearDeployed = false;
pilot.airbrakeDeployed = false;
// A controller carries blend and toggle state between frames. A new life must
// start with a new controller so no manual override or trim-like state leaks
// through the respawn boundary.
this.controllers.delete(pilot.id);
if (this.onPilotRespawn) this.onPilotRespawn(pilot.id);
}
private runAIConsensus(bot: Pilot, dt: number) {
const mapRadius = MAP_REGISTRY[this.selectedMapId]?.world?.radius ?? 6000;
BotAISystem.runAIConsensus(
bot,
dt,
this.pilots,
this.groundTargets,
this.skyZones,
(p, prim, sec, d) => this.handleWeaponFiring(p, prim, sec, d),
mapRadius
);
}
private updateGroundDefense(dt: number) {
GroundDefenseSystem.updateGroundDefense(
dt,
this.groundTargets,
this.pilots,
this.projectiles,
this.isMultiplayer,
this.isHost
);
}
private updateCaptureZones(dt: number) {
ObjectiveSystem.updateCaptureZones(
dt,
this.skyZones,
this.pilots,
(team, amt) => {
if (team === 1) this.team1Score += amt;
else if (team === 2) this.team2Score += amt;
},
this.isMultiplayer,
this.isHost
);
this.team1Score = Math.floor(this.team1Score);
this.team2Score = Math.floor(this.team2Score);
}
private updateCampaignMission() {
if (!this.campaignMission || !this.campaignState || this.matchEnded) return;
const player = this.pilots.find(p => p.id === this.playerPilotId);
const progress =
this.campaignMission.objectiveType === "destroy-ground"
? this.targetsDestroyedThisMatch
: player?.kills ?? 0;
this.campaignState.progress = Math.min(
progress,
this.campaignMission.targetCount
);
if (this.campaignState.progress >= this.campaignMission.targetCount) {
this.campaignState.completed = true;
this.xpEarnedThisMatch += this.campaignMission.xpReward;
this.endGame(true);
}
}
public forceEndGame(playerWon: boolean) {
if (this.matchEnded) return;
this.matchEnded = true;
if (playerWon) this.xpEarnedThisMatch += 400;
this.onGameOverCallback(playerWon, this.xpEarnedThisMatch);
}
private endGame(victoryOverride?: boolean) {
const playerWon = victoryOverride ?? (
this.campaignMission
? this.campaignState?.completed === true
: this.team1Score >= this.team2Score
);
this.onMatchEnd?.(playerWon);
this.forceEndGame(playerWon);
}
}