/** * @license * SPDX-License-Identifier: Apache-2.0 */ import * as THREE from "three"; import { Vector3 } from "three"; import { Pilot, Projectile, GroundTarget, WeaponType, AmmoBelt } from "../types"; import { physical, destructible } from "../types/components"; import { WEAPON_SPECS_MAP } from "./aircraftData"; import { AIRCRAFT_DEFINITIONS } from "./content/aircraft/registry"; import { MODIFICATIONS } from "./content/modifications/modificationData"; import { FlightPhysicsEngine } from "./flightModel"; import { generateId, closestPointOnSegment, getPlaneHitRadius, LOCAL_FORWARD } from "./math"; import { getTerrainHeight } from "./terrainModel"; import { getCockpitDef } from "./content/aircraft/cockpitRegistry"; import { solveGunConvergenceLocal } from "./weaponConvergence"; function beltName(belt: AmmoBelt | string): string { return String(belt); } export function getProjectileReleaseState( pilot: Pilot, type: WeaponType, hardpointIndex?: number ): { position: Vector3; velocity: Vector3 } { const rotation = new THREE.Quaternion(pilot.qx, pilot.qy, pilot.qz, pilot.qw); const spec = WEAPON_SPECS_MAP[type]; const aircraftDef = AIRCRAFT_DEFINITIONS.find( definition => definition.specs.id === pilot.aircraftId ); const currentAmmo = pilot.ammo[type] ?? spec.ammoCapacity; const releasedCount = Math.max(0, spec.ammoCapacity - currentAmmo); const hardpointList = type === WeaponType.BOMB ? aircraftDef?.hardpoints.bombPositions : type === WeaponType.ROCKET ? aircraftDef?.hardpoints.rocketPositions : aircraftDef?.hardpoints.positions; const selectedHardpointIndex = hardpointIndex ?? releasedCount; const hardpoint = hardpointList?.length ? hardpointList[selectedHardpointIndex % hardpointList.length] : null; const hardpointLocal = hardpoint ? new Vector3(hardpoint.x, hardpoint.y, hardpoint.z) : LOCAL_FORWARD.clone().multiplyScalar(12); const position = new Vector3(pilot.x, pilot.y, pilot.z); position.add(hardpointLocal.clone().applyQuaternion(rotation)); const velocity = new Vector3(pilot.vx, pilot.vy, pilot.vz); if (type === WeaponType.BOMB) { velocity.add( new Vector3(0, -1, 0) .applyQuaternion(rotation) .multiplyScalar(3.5) ); } else { let directionLocal = LOCAL_FORWARD.clone(); const convergenceM = aircraftDef?.hardpoints.gunConvergenceM; const cockpitDef = aircraftDef ? getCockpitDef(aircraftDef.specs.id) : undefined; if ( type !== WeaponType.ROCKET && hardpoint && convergenceM !== undefined && cockpitDef ) { directionLocal = solveGunConvergenceLocal( hardpointLocal, cockpitDef, convergenceM ).directionLocal; } const direction = directionLocal.applyQuaternion(rotation).normalize(); velocity.addScaledVector(direction, spec.muzzleVelocity); } return { position, velocity }; } export interface EngineCallbacks { registerKill: (killerId: string, victimId: string, weapon: string) => void; registerGroundTargetKill: (killerId: string, target: GroundTarget) => void; onProjectileSpawn?: (type: WeaponType) => void; onProjectileImpact?: ( type: WeaponType, position: Vector3, ownerId: string ) => void; onGroundTargetDamage?: (targetId: string, hp: number, isDead: boolean) => void; onHitEnemy?: (killerId: string, targetId: string, isGround: boolean) => void; onPlayerDamage?: ( shooterId: string, targetId: string, damage: number, bulletType: string, hitSpotLocal: Vector3 ) => void; // Fires with the actual local-space impact offset in metres (not normalised) // so the voxel deformation system can destroy voxels in the right place. onVoxelHit?: ( targetId: string, localOffsetMeters: Vector3, blastMeters: number ) => void; // Returns the centre of the first voxel struck by the segment in local aircraft // space, null if the aircraft uses voxels but the segment misses all of them, // or undefined if the aircraft has no voxel definition (use closest-point fallback). getVoxelImpact?: ( targetId: string, segStartLocal: Vector3, segEndLocal: Vector3 ) => THREE.Vector3 | null | undefined; } export class ProjectileSystem { public static spawnProjectile( pilot: Pilot, type: WeaponType, projectiles: Projectile[], onProjectileSpawn?: (type: WeaponType) => void, random: () => number = Math.random ) { const rot = new THREE.Quaternion(pilot.qx, pilot.qy, pilot.qz, pilot.qw); const spec = WEAPON_SPECS_MAP[type]; let dispersionAmount = spec.dispersion; pilot.modifications?.forEach(modId => { const mod = MODIFICATIONS.find(m => m.id === modId); if (mod?.effects.dispersion !== undefined) { dispersionAmount *= (1 + mod.effects.dispersion); } }); const spread = new THREE.Vector3( (random() - 0.5) * dispersionAmount, (random() - 0.5) * dispersionAmount, (random() - 0.5) * dispersionAmount ).applyQuaternion(rot); const release = getProjectileReleaseState(pilot, type); if (type !== WeaponType.BOMB) { const dir = release.velocity .clone() .sub(new Vector3(pilot.vx, pilot.vy, pilot.vz)) .normalize() .add(spread) .normalize(); release.velocity.set(pilot.vx, pilot.vy, pilot.vz) .addScaledVector(dir, spec.muzzleVelocity); } const projectile: Projectile = { id: generateId(), ownerId: pilot.id, ownerTeam: pilot.team, type, belt: pilot.ammoBelt, x: release.position.x, y: release.position.y, z: release.position.z, vx: release.velocity.x, vy: release.velocity.y, vz: release.velocity.z, life: type === WeaponType.ROCKET ? 4.5 : type === WeaponType.BOMB ? 7.0 : 1.8, isRocket: type === WeaponType.ROCKET || type === WeaponType.BOMB }; if (pilot.id === "player" && onProjectileSpawn) { onProjectileSpawn(type); } projectiles.push(projectile); } private static lastPosTmp = new Vector3(); private static currentPosTmp = new Vector3(); private static targetPosTmp = new Vector3(); private static closestTmp = new Vector3(); private static localImpactWorldTmp = new Vector3(); private static rotInvTmp = new THREE.Quaternion(); private static eulerTmp = new THREE.Euler(); private static relativeOffsetLocalTmp = new Vector3(); private static localOffsetMetersTmp = new Vector3(); private static localSegStartTmp = new Vector3(); private static localSegEndTmp = new Vector3(); public static updateProjectiles( dt: number, projectiles: Projectile[], pilots: Pilot[], groundTargets: GroundTarget[], mapId: string, callbacks: EngineCallbacks ) { for (let i = projectiles.length - 1; i >= 0; i--) { const p = projectiles[i]; p.life -= dt; ProjectileSystem.lastPosTmp.set(p.x, p.y, p.z); if (p.type === WeaponType.BOMB) { p.vy -= 9.8 * dt; } p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; ProjectileSystem.currentPosTmp.set(p.x, p.y, p.z); let hasHit = false; // Pilot check for (const target of pilots) { const epTarget = target as any; if (target.id === p.ownerId) continue; if (target.team === p.ownerTeam) continue; const tPhys = physical(target.entity); const tDestr = destructible(target.entity); const tDm = tDestr.damageModel!; if (tDm.fuselage <= 0) continue; if ((epTarget.invulnerableTimer ?? 0) > 0) continue; ProjectileSystem.targetPosTmp.set(tPhys.x, tPhys.y, tPhys.z); closestPointOnSegment( ProjectileSystem.lastPosTmp, ProjectileSystem.currentPosTmp, ProjectileSystem.targetPosTmp, ProjectileSystem.closestTmp ); const distToPlaneCenter = ProjectileSystem.closestTmp.distanceTo(ProjectileSystem.targetPosTmp); const hitRadius = getPlaneHitRadius(target.specs); if (distToPlaneCenter < hitRadius) { ProjectileSystem.rotInvTmp .set(tPhys.qx, tPhys.qy, tPhys.qz, tPhys.qw) .invert(); // Transform projectile segment endpoints into local aircraft space ProjectileSystem.localSegStartTmp .copy(ProjectileSystem.lastPosTmp) .sub(ProjectileSystem.targetPosTmp) .applyQuaternion(ProjectileSystem.rotInvTmp); ProjectileSystem.localSegEndTmp .copy(ProjectileSystem.currentPosTmp) .sub(ProjectileSystem.targetPosTmp) .applyQuaternion(ProjectileSystem.rotInvTmp); // Precise voxel traversal when the target aircraft has a voxel def. // getVoxelImpact returns: // Vector3 → voxel aircraft, cell struck — use as impact centre // null → voxel aircraft, segment misses all cells — skip hit // undefined→ no voxel def — fall back to closest-point method const voxResult = callbacks.getVoxelImpact?.( target.id, ProjectileSystem.localSegStartTmp, ProjectileSystem.localSegEndTmp ); if (voxResult === null) break; // voxel aircraft, true miss if (voxResult !== undefined) { ProjectileSystem.localOffsetMetersTmp.copy(voxResult); } else { // Closest-point fallback for aircraft without voxel definitions ProjectileSystem.localImpactWorldTmp .copy(ProjectileSystem.closestTmp) .sub(ProjectileSystem.targetPosTmp); ProjectileSystem.localOffsetMetersTmp .copy(ProjectileSystem.localImpactWorldTmp) .applyQuaternion(ProjectileSystem.rotInvTmp); } ProjectileSystem.relativeOffsetLocalTmp .copy(ProjectileSystem.localOffsetMetersTmp) .divideScalar(hitRadius); let finalDmg = WEAPON_SPECS_MAP[p.type].damage; const owner = pilots.find(pl => pl.id === p.ownerId); owner?.modifications?.forEach(modId => { const mod = MODIFICATIONS.find(m => m.id === modId); if (mod?.effects.damage !== undefined) { finalDmg *= (1 + mod.effects.damage); } }); const belt = beltName(p.belt); if (belt === "Armor-Piercing") finalDmg *= 1.3; if (belt === "Incendiary") finalDmg *= 0.85; FlightPhysicsEngine.applyDamage(target, finalDmg, String(p.type), ProjectileSystem.relativeOffsetLocalTmp); hasHit = true; if (callbacks.onPlayerDamage) { callbacks.onPlayerDamage( p.ownerId, target.id, finalDmg, String(p.type), ProjectileSystem.relativeOffsetLocalTmp ); } // Voxel deformation — only fired when a specific cell was struck. // Blast radii are explicit per weapon type, not derived from damage values. if (voxResult !== undefined && callbacks.onVoxelHit) { const blastM = p.type === WeaponType.BOMB ? 2.50 : p.type === WeaponType.ROCKET ? 0.75 : 0; callbacks.onVoxelHit(target.id, ProjectileSystem.localOffsetMetersTmp, blastM); } if (callbacks.onHitEnemy) { callbacks.onHitEnemy(p.ownerId, target.id, false); } if (tDm.fuselage <= 0) { callbacks.registerKill(p.ownerId, target.id, String(p.type)); } break; } } // Ground target check if (!hasHit) { for (const target of groundTargets) { const gtDestr = destructible(target.entity); if (gtDestr.isDead || target.team === p.ownerTeam) continue; const gtPhys = physical(target.entity); ProjectileSystem.targetPosTmp.set(gtPhys.x, gtPhys.y, gtPhys.z); closestPointOnSegment( ProjectileSystem.lastPosTmp, ProjectileSystem.currentPosTmp, ProjectileSystem.targetPosTmp, ProjectileSystem.closestTmp ); const distToTgt = ProjectileSystem.closestTmp.distanceTo(ProjectileSystem.targetPosTmp); if (distToTgt < 24) { let dmg = WEAPON_SPECS_MAP[p.type].damage; const owner = pilots.find(pl => pl.id === p.ownerId); owner?.modifications?.forEach(modId => { const mod = MODIFICATIONS.find(m => m.id === modId); if (mod?.effects.damage !== undefined) { dmg *= (1 + mod.effects.damage); } }); const belt = beltName(p.belt); if (belt === "Armor-Piercing") dmg *= 1.8; if (p.type === WeaponType.BOMB) dmg *= 2.5; gtDestr.hp = Math.max(0, gtDestr.hp - dmg); if (gtDestr.hp <= 0) gtDestr.isDead = true; if (callbacks.onGroundTargetDamage) { callbacks.onGroundTargetDamage(target.id, gtDestr.hp, gtDestr.isDead); } if (callbacks.onHitEnemy) { callbacks.onHitEnemy(p.ownerId, target.id, true); } hasHit = true; if (gtDestr.isDead) { callbacks.registerGroundTargetKill(p.ownerId, target); } break; } } } const terrainHeight = getTerrainHeight(p.x, p.z, mapId).height; if (!hasHit && p.y <= Math.max(12, terrainHeight)) { hasHit = true; if (p.isRocket) { this.triggerSplashDamage( ProjectileSystem.currentPosTmp, p.ownerId, p.ownerTeam, p.type, groundTargets, pilots, callbacks ); } } if (hasHit || p.life <= 0) { if (hasHit && callbacks.onProjectileImpact) { callbacks.onProjectileImpact(p.type, ProjectileSystem.currentPosTmp, p.ownerId); } projectiles.splice(i, 1); } } } public static triggerSplashDamage( epicenter: Vector3, ownerId: string, team: number, type: WeaponType, groundTargets: GroundTarget[], pilots: Pilot[], callbacks: EngineCallbacks ) { const splashRad = type === WeaponType.BOMB ? 180 : 70; const baseSplash = type === WeaponType.BOMB ? 350 : 150; groundTargets.forEach(t => { if (t.isDead || t.team === team) return; const d = epicenter.distanceTo(new Vector3(t.x, t.y, t.z)); if (d < splashRad) { const falloff = 1 - d / splashRad; t.hp = Math.max(0, t.hp - baseSplash * falloff); if (t.hp <= 0) { t.isDead = true; } if (callbacks.onGroundTargetDamage) { callbacks.onGroundTargetDamage(t.id, t.hp, t.isDead); } if (callbacks.onHitEnemy) { callbacks.onHitEnemy(ownerId, t.id, true); } if (t.isDead) { callbacks.registerGroundTargetKill(ownerId, t); } } }); pilots.forEach(p => { if (p.team === team) return; if (p.damage.fuselage <= 0) return; const ep = p as any; if ((ep.invulnerableTimer ?? 0) > 0) return; const d = epicenter.distanceTo(new Vector3(p.x, p.y, p.z)); if (d < splashRad) { const falloff = 1 - d / splashRad; const relativeOffset = new Vector3(0, -0.5, 0); FlightPhysicsEngine.applyDamage(p, baseSplash * falloff, String(type), relativeOffset); if (callbacks.onVoxelHit) { // Blast arrives from the epicenter direction. Transform that vector // to local aircraft space and clamp to wing-tip distance so the // impact lands on a surface voxel rather than empty space. const rotInv = new THREE.Quaternion(p.qx, p.qy, p.qz, p.qw) .invert(); const localDir = new Vector3( p.x - epicenter.x, p.y - epicenter.y, p.z - epicenter.z ).applyQuaternion(rotInv); const localLen = localDir.length(); if (localLen > 7) localDir.multiplyScalar(7 / localLen); // Blast radius at the pilot scales with proximity: 0.3 m at splashRad edge, 2.5 m at centre const blastAtImpact = 0.3 + falloff * 2.2; callbacks.onVoxelHit(p.id, localDir, blastAtImpact); } if (callbacks.onHitEnemy) { callbacks.onHitEnemy(ownerId, p.id, false); } if (p.damage.fuselage <= 0) { callbacks.registerKill(ownerId, p.id, String(type)); } } }); } }