airframe.io / src /game /projectileSystem.ts
LATTICEWORKS's picture
feat: integrate Google Earth 3D Tiles and optimize engine hooks
565f6e7
Raw
History Blame Contribute Delete
16.7 kB
/**
* @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));
}
}
});
}
}