Prototype finite interceptor motor and coast

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
2026-11-02 23:50:33 +03:00
parent b377a5d8d2
commit 143d9b2858
4 changed files with 71 additions and 11 deletions
+4
View File
@@ -1140,7 +1140,11 @@ mod engine_tests {
let mut journal = SceneJournal::default();
journal.fixture_push(SceneEvent { at: 19, pos: DVec3::new(2.0, 66.0, 0.0), kind: SceneKind::MissileLaunch { missile: 2, sam: 5, target: 1, kind: MissileKind::M48n6, sound: "airdef:launch.s400_eject", pitch: 1.0 } });
journal.fixture_push(SceneEvent { at: 24, pos: DVec3::new(2.0, 75.0, 0.0), kind: SceneKind::MotorIgnition { missile: 2, kind: MissileKind::M48n6, sound: Some("airdef:launch.s400") } });
journal.fixture_push(SceneEvent { at: 50, pos: DVec3::new(2.0, 75.0, 100.0), kind: SceneKind::MotorBurnout { missile: 2, kind: MissileKind::M48n6 } });
rec.scene = Some(journal);
assert!(rec.missile_is_lit(&rec.tracks[0], 49.0, 5));
assert!(!rec.missile_is_lit(&rec.tracks[0], 50.0, 5));
assert!(rec.missile_is_lit(&rec.tracks[0], 49.0, 5), "seek back restores the recorded state");
let mut p = player(&rec);
let mut out = Out::default();
p.heard = 18.0;
+30 -9
View File
@@ -1280,9 +1280,20 @@ pub const MAX_COAST: u64 = 240;
/// Ticks the seeker keeps a target it lost sight of (it looks again round the last place).
const REACQUIRE: u64 = 12;
impl MissileKind {
/// Fictional game motor duration in ticks, independent of the flight lifetime.
pub fn burn_ticks(self) -> u32 {
let spec = self.spec();
if self == MissileKind::P1sun { spec.life } else { spec.life * 2 / 3 }
}
}
impl Missile {
/// Whether the main motor is running (cold ejection has no exhaust).
pub fn engine_lit(&self) -> bool { self.age > self.kind.spec().cold }
/// Cold ejection and post-burn coast have no powered exhaust.
pub fn engine_lit(&self) -> bool {
let spec = self.kind.spec();
self.age > spec.cold && self.age <= spec.cold + self.kind.burn_ticks()
}
}
impl MissileKind {
@@ -2949,6 +2960,7 @@ impl Sim {
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
let (mut blocked, mut scrubbed) = (0u32, 0u32);
let mut ignitions = Vec::new();
let mut burnouts = Vec::new();
#[cfg(test)]
let mut scrub_log = Vec::new();
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect();
@@ -2968,11 +2980,17 @@ impl Sim {
m.age += 1;
let spec = m.kind.spec();
let target = self.drones.iter().find(|d| d.id == m.target && d.alive());
let lit = m.age > spec.cold;
let lit = m.engine_lit();
let coast = m.age > spec.cold + m.kind.burn_ticks();
if m.age == spec.cold + m.kind.burn_ticks() + 1 {
burnouts.push((m.pos, m.id, m.kind));
}
let speed = if !lit {
(m.vel.length() * 0.97).max(0.4)
} else {
} else if lit {
(m.vel.length() + spec.accel).min(spec.speed)
} else {
m.vel.length() * 0.995
};
let dir = m.vel.normalize_or(DVec3::Y);
// What it knows: the link's newest track, and its own seeker once the target is in its reach and in view.
@@ -2982,7 +3000,7 @@ impl Sim {
m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
}
if let Some(t) = target
&& lit
&& m.age > spec.cold
&& m.age >= spec.boost
&& m.kind.seeker() > 0.0
{
@@ -3027,7 +3045,7 @@ impl Sim {
done.push((m.id, target.map(|t| t.id), false, m.sam));
continue;
}
turn(dir, want, spec.turn)
turn(dir, want, spec.turn * if coast { (speed / spec.speed).clamp(0.15, 1.0) } else { 1.0 })
}
// After a cold launch it tips over towards the target while the motor burns.
Some((at, _)) if lit && spec.cold > 0 => turn(dir, at - m.pos, spec.turn * 0.5),
@@ -3070,9 +3088,9 @@ impl Sim {
let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0);
if (rel0 + delta * k).length() < spec.fuse && world.map(|w| w.0).or(waiting).is_none_or(|w| k <= w) {
// A target faster than the interceptor is hard to hit.
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
let ratio = (speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0) * launchers.get(&m.sam).map_or(1.0, |(at, max)| why::reach_fit(*max, at.distance(m.pos)));
let kill = self.rng.chance(spec.pk * fit * ratio / t.kind.spec().stealth.sqrt());
let kill = self.rng.chance(spec.pk * fit * ratio);
missed += u32::from(!kill);
done.push((m.id, Some(t.id), kill, m.sam));
continue;
@@ -3120,6 +3138,9 @@ impl Sim {
for (pos, missile, kind, sound) in ignitions {
self.scene_event(pos, record::SceneKind::MotorIgnition { missile, kind, sound });
}
for (pos, missile, kind) in burnouts {
self.scene_event(pos, record::SceneKind::MotorBurnout { missile, kind });
}
#[cfg(test)]
self.scrubs.extend(scrub_log);
self.note(|t| {
@@ -3154,7 +3175,7 @@ impl Sim {
let spec = m.kind.spec();
let rot = look(m.vel, 0.0) * DQuat::from_rotation_z(m.age as f64 * 0.3);
out.model(m.id, spec.model, m.pos, rot, spec.scale, false);
if m.age > spec.cold && spec.flame > 0.0 {
if m.engine_lit() && spec.flame > 0.0 {
let back = (spec.rear - 8.0) / 16.0 * spec.scale + spec.flame;
let size = spec.flame * if m.age < 40 { 1.2 } else { 0.8 } * (0.85 + 0.15 * self.vis.f());
out.model(m.id + 1, "airdef:flame", m.pos + rot * DVec3::new(0.0, 0.0, back), rot, size, true);
+13 -2
View File
@@ -253,6 +253,7 @@ pub struct Event {
pub enum SceneKind {
MissileLaunch { missile: u32, sam: u32, target: u32, kind: MissileKind, sound: &'static str, pitch: f32 },
MotorIgnition { missile: u32, kind: MissileKind, sound: Option<&'static str> },
MotorBurnout { missile: u32, kind: MissileKind },
TrackAcquired { sam: u32, target: u32, ever_own: bool },
TrackLost { sam: u32, target: u32 },
TrackSource { sam: u32, target: u32, ever_own: bool },
@@ -281,6 +282,7 @@ pub struct SceneJournal {
fire_events: Vec<usize>,
launches: HashMap<u32, usize>,
ignitions: HashMap<u32, usize>,
burnouts: HashMap<u32, usize>,
}
impl SceneJournal {
@@ -296,6 +298,7 @@ impl SceneJournal {
match event.kind {
SceneKind::MissileLaunch { missile, .. } => { self.launches.insert(missile, index); }
SceneKind::MotorIgnition { missile, .. } => { self.ignitions.insert(missile, index); }
SceneKind::MotorBurnout { missile, .. } => { self.burnouts.insert(missile, index); }
SceneKind::FireStarted { .. } | SceneKind::FireChanged { .. } | SceneKind::FireEnded { .. } => self.fire_events.push(index),
_ => {}
}
@@ -623,9 +626,17 @@ impl Recording {
/// Recorded ignition takes precedence; older/incomplete tracks retain their original fallback.
pub fn missile_is_lit(&self, tr: &Track, t: f64, cold: u32) -> bool {
if let Some(event) = self.missile_ignition(tr.id) { return t >= event.at as f64; }
if let Some(scene) = &self.scene
&& let Some(index) = scene.burnouts.get(&tr.id)
&& t >= scene.events[*index].at as f64 { return false; }
if let Some(event) = self.missile_ignition(tr.id) {
let burn = match tr.what { What::Missile(kind, _) => kind.burn_ticks(), _ => return false };
return t >= event.at as f64 && t < event.at as f64 + burn as f64;
}
if self.missile_launch(tr.id).is_some() && self.scene.as_ref().is_some_and(|s| !s.truncated) { return false; }
t - tr.first as f64 > cold as f64
let age = t - tr.first as f64;
let burn = match tr.what { What::Missile(kind, _) => kind.burn_ticks(), _ => return false };
age > cold as f64 && age <= (cold + burn) as f64
}
fn note_tracks(&mut self, sams: &[Sam]) {
+24
View File
@@ -1854,3 +1854,27 @@ fn destroying_a_pending_carrier_and_loaded_truck_does_not_restore_stock_on_repai
assert!(!sim.sams[1].ground_request);
assert_eq!((sim.sams[0].ground_stock, sim.sams[2].ground_stock), (0, 0));
}
#[test]
fn motor_burnout_reduces_speed_and_stops_powered_effects() {
let kind = MissileKind::Pac3;
let spec = kind.spec();
let mut sim = Sim::new(120);
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 2000.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
let (target, id) = (sim.drones[0].id, sim.id());
sim.tick = 100;
let fix = Some(Fix { pos: sim.drones[0].pos, vel: DVec3::ZERO, tick: 100, seeker: false });
sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * (spec.speed * 0.8), target, sam: 999, age: spec.cold + kind.burn_ticks() - 2, hold: 0, fix, seen: 0 });
let mut out = Out::default();
sim.step_missiles(&mut Ground::default(), &mut out);
let powered = sim.missiles[0].vel.length();
assert!(sim.missiles[0].engine_lit());
assert!(out.engines.iter().any(|e| e.key == 0x4000_0000 | id));
sim.step_missiles(&mut Ground::default(), &mut Out::default());
let last = sim.missiles[0].vel.length();
let mut out = Out::default();
sim.step_missiles(&mut Ground::default(), &mut out);
assert!(powered <= last && sim.missiles[0].vel.length() < last);
assert!(!sim.missiles[0].engine_lit());
assert!(!out.engines.iter().any(|e| e.key == 0x4000_0000 | id));
}