Spaces:
Sleeping
Sleeping
| /** | |
| * @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); | |
| } | |
| } | |