Stage the cold launch and spool every interceptor's motor (#129)

The interceptor's launch is one sequence: the cap opens, the round is
thrown out unlit and slows under linear gas drag (`cold` ticks), the motor
lights where it has reached, it turns with a growing rate while thrust
spools up (`boost` ticks), and full guidance starts only at `cold + boost`.
A rail launch lights at once but spools over the same `boost` ticks; the
electric P1-SUN has no rocket spool or plume.

S-400/500 now hold the unlit ejection for 20/24 ticks (1.00/1.20 s at
20 Hz); IRIS-T/Tor keep shorter 8-tick throws. Ignition, its sound, flame
and recorded MotorIgnition event all use the missile's current position.
The RM48U strike follows the same 20-tick choreography, counts the throw
in its flight time and starts its recorded smoke at ignition.

Maximum speed, turn, accel, pk and every collision rule are unchanged.

Co-Authored-By: Kimi Code <noreply@kimi.com>
This commit is contained in:
2026-11-06 13:18:01 +03:00
parent a592ece6e3
commit 1261c30724
9 changed files with 399 additions and 36 deletions
+1 -1
View File
@@ -1210,7 +1210,7 @@ mod engine_tests {
assert_eq!(out.sounds.iter().map(|s| s.0).collect::<Vec<_>>(), vec!["airdef:launch.s400_eject"]);
assert!(out.engines.is_empty());
out = Out::default();
p.t = 16.0;
p.t = 10.0 + crate::sim::strike::EJECT_TICKS as f64 + 1.0;
p.sounds(&rec, DVec3::ZERO, &mut out);
p.engines(&rec, DVec3::ZERO, false, &mut out);
assert_eq!(out.sounds.iter().map(|s| s.0).collect::<Vec<_>>(), vec!["airdef:launch.s400"]);
+50 -11
View File
@@ -824,10 +824,15 @@ struct MissileSpec {
speed: f64,
accel: f64,
turn: f64,
/// Ticks of straight flight before guidance.
/// Departure: ticks from ignition to full guidance (spool-up; a cold launch also captures
/// its course); full guidance starts at `cold + boost`.
boost: u32,
/// Thrown out of the tube by gas first; the motor lights this many ticks later.
/// Thrown out of the tube by gas first; the motor lights this many ticks later (#129).
cold: u32,
/// Gas-throw speed out of the tube, blocks a tick (cold-launched kinds only).
eject: f64,
/// The ejection's linear drag, blocks a tick² (cold-launched kinds only).
eject_drag: f64,
pk: f64,
fuse: f64,
life: u32,
@@ -843,6 +848,31 @@ struct MissileSpec {
voice: (&'static str, f32, f32),
}
impl MissileSpec {
/// The launch choreography is one sequence of stages (#129): the cap opens on the ground
/// (`launch::OPEN_TICKS`), the round is thrown out of the tube unlit and slows under drag
/// (`age <= cold`), the motor lights where it has reached (`age == cold + 1`), it tips onto
/// its course with a growing rate while the motor spools up (`cold < age < cold + boost`),
/// and full guidance — terrain lofting, the seeker, full turn — runs from `age >= cold + boost`.
/// Rail-launched kinds (`cold == 0`) light at once but spool up over the same `boost` ticks:
/// their stages are ignition, departure, guidance.
fn guided(&self, age: u32) -> bool {
age >= self.cold + self.boost
}
/// The departure's course-capture rate as a share of the full turn (0..=1).
fn departure_ramp(&self, age: u32) -> f64 {
(age.saturating_sub(self.cold) as f64 / self.boost.max(1) as f64).clamp(0.0, 1.0)
}
/// The motor's spool-up as a share of the full acceleration (0..=1). A rail launch spools over
/// the same `boost` ticks its departure lasts; an electric kind has no rocket motor to light.
fn spool(&self, age: u32, guided: bool) -> f64 {
if guided || self.flame <= 0.0 {
return 1.0;
}
0.45 + 0.55 * self.departure_ramp(age)
}
}
impl MissileKind {
/// How thick its motor smoke is (its size); `None` for an electric one.
pub fn trail_scale(self) -> Option<f32> {
@@ -2543,7 +2573,7 @@ impl Sim {
.map(|tr| Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
let spec = kind.spec();
let id = self.id();
let v0 = if spec.cold > 0 { 0.7 } else { 0.8 };
let v0 = if spec.cold > 0 { spec.eject } else { 0.8 };
self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0, hold: 0, fix, seen: 0 });
if spec.cold > 0 {
// Thrown out by gas: a puff, a thump; the motor lights later.
@@ -2612,10 +2642,15 @@ impl Sim {
if m.age == spec.cold + m.kind.burn_ticks() + 1 {
burnouts.push((m.pos, m.id, m.kind));
}
// The launch's stages (#129): ejection (unlit, gas drag), ignition (the motor lights
// where the round has reached), departure (spool-up and course capture) and guidance.
// Guidance never runs before ignition: it starts at `cold + boost`, the motor at `cold`.
let guided = spec.guided(m.age);
let speed = if m.age <= spec.cold {
(m.vel.length() * 0.97).max(0.4)
// The gas throw bleeds off linearly; the round is never left hanging still.
(m.vel.length() - spec.eject_drag).max(spec.eject * 0.25)
} else if lit {
(m.vel.length() + spec.accel).min(spec.speed)
(m.vel.length() + spec.accel * spec.spool(m.age, guided)).min(spec.speed)
} else {
m.vel.length() * 0.995
};
@@ -2627,8 +2662,7 @@ impl Sim {
m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
}
if let Some(t) = target
&& m.age > spec.cold
&& m.age >= spec.boost
&& guided
&& m.kind.seeker() > 0.0
{
let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6);
@@ -2643,14 +2677,14 @@ impl Sim {
}
}
// Nothing new for too long and no seeker on it: the interceptor has lost its target.
if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.boost + 20 {
if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.cold + spec.boost + 20 {
expired += 1;
done.push((m.id, None, false, m.sam));
continue;
}
let aim = m.fix.map(|f| (f.est(now), f.vel));
let new = match aim {
Some((at, vel)) if m.age >= spec.boost => {
Some((at, vel)) if guided => {
// Time to go by the closing speed: head-on targets come to meet it.
let los = (at - m.pos).normalize_or_zero();
let closing = (speed - vel.dot(los)).max(1.0);
@@ -2674,8 +2708,13 @@ impl Sim {
}
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),
// Departure: after a cold launch it tips onto the target's course while the motor
// spools up, at a rate that grows into the full one — no snap from the vertical to
// the horizontal (#129). A rail launch already points along its rail; it keeps that
// course until guidance can loft it over the land, as before.
Some((at, _)) if lit && spec.cold > 0 => {
turn(dir, at - m.pos, spec.turn * (0.2 + 0.8 * spec.departure_ramp(m.age)))
}
_ => dir,
};
let before = m.pos;
+265 -3
View File
@@ -2213,6 +2213,8 @@ fn spent_motor_has_less_maneuver_at_low_speed() {
assert!(powered.x > coast.x && coast.x > 0.0, "{powered:?} {coast:?}");
}
// #129: the staged launch's exact speed rules: gas-drag ejection with a floor, a spooled
// departure that grows into full acceleration, full power at guidance, the coast drag after.
#[test]
fn cold_ejection_power_and_coast_use_their_own_speed_rules() {
let kind = MissileKind::M48n6;
@@ -2228,10 +2230,270 @@ fn cold_ejection_power_and_coast_use_their_own_speed_rules() {
sim.step_missiles(&mut Ground::default(), &mut Out::default());
sim.missiles[0].vel.length()
};
assert!((fly(0, 2.0) - 2.0 * 0.97).abs() < 1e-9, "cold ejection drag");
assert!((fly(spec.cold, 2.0) - (2.0 + spec.accel)).abs() < 1e-9, "powered acceleration");
assert!((fly(0, spec.eject) - (spec.eject - spec.eject_drag)).abs() < 1e-9, "cold ejection drag");
assert!((fly(spec.cold - 1, 0.1) - spec.eject * 0.25).abs() < 1e-9, "cold ejection floor near ignition");
assert!((fly(0, 0.2) - spec.eject * 0.25).abs() < 1e-9, "cold ejection floor");
let spool0 = 0.45 + 0.55 / f64::from(spec.boost);
assert!((fly(spec.cold, 2.0) - (2.0 + spec.accel * spool0)).abs() < 1e-9, "ignition spool-up");
assert!((fly(spec.cold + spec.boost - 1, 2.0) - (2.0 + spec.accel)).abs() < 1e-9, "powered acceleration");
assert!((fly(spec.cold + kind.burn_ticks(), 2.0) - 2.0 * 0.995).abs() < 1e-9, "coast drag");
assert!((fly(0, 0.2) - 0.4).abs() < 1e-9, "cold ejection minimum");
}
// ---- #129: the staged cold launch. Ejection (unlit gas throw) → ignition where the round has
// reached → departure (spool-up and course capture at a growing rate) → guidance at cold + boost.
/// Every cold-launched kind walks its stages in order; before ignition there is no motor voice,
/// no flame and no steering, the speed is continuous across ignition, and the turn each tick
/// never exceeds what the stage allows.
#[test]
fn cold_launch_stages_run_in_order_and_guidance_follows_ignition() {
for &kind in &[MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT, MissileKind::M9m338] {
let spec = kind.spec();
let mut sim = Sim::new(120);
// A still target well off the vertical: the tip-over has somewhere to turn.
let target_pos = DVec3::new(300.0, 120.0, 300.0);
sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos);
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 64.0, 0.0), vel: DVec3::Y * spec.eject, target, sam: 999, age: 0, hold: 0,
fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 });
sim.begin_recording_if_active();
let mut previous_speed = spec.eject;
let mut previous_dir = DVec3::Y;
let mut saw_ignition = false;
for age in 1..=(spec.cold + spec.boost + 2) {
sim.tick += 1;
let mut out = Out::default();
sim.step_missiles(&mut Ground::default(), &mut out);
let m = sim.missiles.iter().find(|m| m.id == id).unwrap_or_else(|| panic!("{kind:?} age {age}"));
let speed = m.vel.length();
let dir = m.vel.normalize_or(DVec3::Y);
let voice = out.engines.iter().any(|e| e.key == (0x4000_0000 | id));
let flame = out.particles.iter().any(|p| p.kind == "minecraft:flame");
assert_eq!(voice, m.engine_lit(), "{kind:?} age {age}: the motor voice runs only when lit");
if !m.engine_lit() {
assert!(!voice && !flame, "{kind:?} age {age}: powered effects before ignition");
assert!((speed - (previous_speed - spec.eject_drag)).abs() < 1e-9, "{kind:?} age {age}: ejection drag");
assert!(dir.angle_between(DVec3::Y) < 1e-9, "{kind:?} age {age}: steering before ignition");
} else if !saw_ignition {
saw_ignition = true;
assert_eq!(m.age, spec.cold + 1, "{kind:?}: ignition one tick after the ejection ends");
assert!(speed > previous_speed - 1e-9, "{kind:?}: speed jumps down across ignition ({previous_speed} -> {speed})");
assert!(out.sounds.iter().any(|s| s.0 == sound::LAUNCH_S400 || s.0 == sound::LAUNCH_S500), "{kind:?}: ignition sound");
let events = sim.rec.as_ref().unwrap().scene.as_ref().unwrap().events();
assert!(events.iter().any(|e| matches!(e.kind, record::SceneKind::MotorIgnition { missile, .. } if missile == id)), "{kind:?}: ignition recorded");
}
let allowed = if spec.guided(m.age) {
spec.turn
} else if m.engine_lit() {
// Departure: the rate grows into the full one; no snap at the boundary.
spec.turn * (0.2 + 0.8 * spec.departure_ramp(m.age))
} else {
0.0
};
assert!(previous_dir.angle_between(dir) <= allowed + 1e-9, "{kind:?} age {age}: turned past the stage's rate");
previous_speed = speed;
previous_dir = dir;
}
assert!(saw_ignition, "{kind:?}: never lit");
let m = sim.missiles.iter().find(|m| m.id == id).unwrap();
assert!(m.vel.normalize().angle_between(DVec3::Y) > 0.3, "{kind:?}: still vertical after guidance started");
}
}
/// The invariant the issue is about: with a longer ignition delay the old `age >= boost` guidance
/// would have switched on before the motor existed. Now guidance implies ignition, for every
/// profile and every age, and the seeker opens with guidance, not with the raw boost counter.
#[test]
fn guidance_never_starts_before_ignition_for_any_profile() {
for kind in [
MissileKind::Pac3, MissileKind::M57e6, MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT,
MissileKind::Amraam, MissileKind::M9m317, MissileKind::M9m338, MissileKind::P1sun,
] {
let spec = kind.spec();
for age in 0..=spec.life.min(2000) {
assert!(!spec.guided(age) || age > spec.cold, "{kind:?} age {age}: guidance before ignition (cold {})", spec.cold);
}
}
}
/// Across the whole staging the speed changes smoothly (no jumps beyond the stage's own drag or
/// full acceleration) and the per-tick turn never exceeds the profile's rate, for every kind.
/// A rocket's first powered tick is a throttled one for rail and cold launches alike (#129).
#[test]
fn launch_accel_and_turn_stay_continuous_for_every_interceptor() {
for kind in [
MissileKind::Pac3, MissileKind::M57e6, MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT,
MissileKind::Amraam, MissileKind::M9m317, MissileKind::M9m338, MissileKind::P1sun,
] {
let spec = kind.spec();
let mut sim = Sim::new(120);
let target_pos = DVec3::new(400.0, 140.0, 400.0);
sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos);
let (target, id) = (sim.drones[0].id, sim.id());
let v0 = if spec.cold > 0 { spec.eject } else { 0.8 };
sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 64.0, 0.0), vel: DVec3::Y * v0, target, sam: 999, age: 0, hold: 0,
fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 });
let mut previous_speed = v0;
let mut previous_dir = DVec3::Y;
let mut first_power: Option<f64> = None;
for _ in 0..120 {
sim.tick += 1;
sim.step_missiles(&mut Ground::default(), &mut Out::default());
let Some(m) = sim.missiles.iter().find(|m| m.id == id) else { break };
let speed = m.vel.length();
let dir = m.vel.normalize_or(DVec3::Y);
assert!(speed - previous_speed <= spec.accel + 1e-9, "{kind:?}: speed jumped up {previous_speed} -> {speed}");
let allowed_down = spec.eject_drag + previous_speed * 0.005;
assert!(speed >= previous_speed - allowed_down - 1e-9, "{kind:?}: speed jumped down {previous_speed} -> {speed}");
assert!(previous_dir.angle_between(dir) <= spec.turn + 1e-9, "{kind:?}: turned past the profile's rate");
if m.engine_lit() && first_power.is_none() {
first_power = Some(speed - previous_speed);
}
previous_speed = speed;
previous_dir = dir;
}
if spec.flame > 0.0 {
let gain = first_power.unwrap_or_else(|| panic!("{kind:?}: the motor never lit"));
let spooled = spec.accel * (0.45 + 0.55 / f64::from(spec.boost.max(1)));
assert!(gain > 0.0 && gain <= spooled + 1e-9, "{kind:?}: the motor reached full thrust at once ({gain} > {spooled})");
}
}
}
/// The electric P1-SUN is not a rocket: it keeps no flame, no powered smoke and its own voice.
#[test]
fn p1sun_stays_a_ramming_drone_without_a_plume() {
let kind = MissileKind::P1sun;
let spec = kind.spec();
assert_eq!(spec.flame, 0.0);
assert_eq!(spec.cold, 0);
let mut sim = Sim::new(120);
let target_pos = DVec3::new(200.0, 64.0, 0.0);
sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos);
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind, pos: DVec3::ZERO, vel: DVec3::X * 0.8, target, sam: 999, age: 0, hold: 0,
fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 });
for _ in 0..40 {
sim.tick += 1;
let mut out = Out::default();
sim.step_missiles(&mut Ground::default(), &mut out);
assert!(!out.particles.iter().any(|p| p.kind == "minecraft:flame"), "P1-SUN grew a rocket plume");
if let Some(m) = sim.missiles.iter().find(|m| m.id == id) {
assert!(m.engine_lit(), "the electric interceptor's motor state is wrong");
let (event, _, _) = kind.engine_voice();
assert!(out.engines.iter().any(|e| e.key == (0x4000_0000 | id) && e.event == event));
} else {
break;
}
}
}
/// A real S-400 launch records the ejection and the ignition `cold` ticks apart; the replay's lit
/// state follows the recorded ignition, so a seek across it shows one flame and one voice sequence.
#[test]
fn recorded_ignition_is_cold_ticks_after_ejection_and_replay_follows_it() {
let mut sim = Sim::new(0x129);
let sam = sim.id();
sim.sams.push(Sam::new(sam, SamKind::S400, DVec3::new(0.0, 64.0, 0.0), 0.0));
sim.sams[0].cooldown = 0;
let mut out = Out::default();
sim.launch(DroneKind::Kh101, DVec3::new(0.0, 160.0, -600.0), DVec3::Z, DVec3::new(0.0, 64.0, 0.0), &mut out);
let mut ground = Ground::default();
for _ in 0..200 {
// The firing system's own measurement, fresh this tick (as `why::radar_sees` feeds it):
// the test is about the launch stages, not about the scan's chance.
let now = sim.tick;
let tracks: Vec<sight::Track> = sim.drones.iter().filter(|d| d.alive())
.map(|d| sight::Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false })
.collect();
sim.sams[0].tracks.retain(|t| !tracks.iter().any(|n| n.id == t.id));
sim.sams[0].tracks.extend(tracks);
let wanted = ground.take_wanted();
ground.answer(wanted.into_iter().map(|(x, z)| (x, z, 64)).collect(), sim.tick);
out = Out::default();
sim.step(&mut ground, None, &mut out);
if sim.replays.iter().chain(sim.rec.iter()).flat_map(|r| r.scene.iter().flat_map(|s| s.events())).any(|e| matches!(e.kind, record::SceneKind::MotorIgnition { .. })) {
break;
}
}
let rec = sim.rec.as_ref().or_else(|| sim.replays.last()).expect("a recording");
let scene = rec.scene.as_ref().unwrap();
let launch = scene.events().iter().find(|e| matches!(e.kind, record::SceneKind::MissileLaunch { .. })).expect("the ejection is recorded");
let missile = match launch.kind { record::SceneKind::MissileLaunch { missile, .. } => missile, _ => unreachable!() };
let spec = MissileKind::M48n6.spec();
let ignition = rec.missile_ignition(missile).expect("the ignition is recorded");
assert_eq!(ignition.at - launch.at, spec.cold, "ignition {} ticks after the ejection", spec.cold);
let track = rec.track(missile).expect("the interceptor's track");
for t in [ignition.at as f64 - 1.0, ignition.at as f64 - 0.1] {
assert!(!rec.missile_is_lit(track, t, spec.cold), "lit before the recorded ignition at {t}");
}
for t in [ignition.at as f64, ignition.at as f64 + 5.0] {
assert!(rec.missile_is_lit(track, t, spec.cold), "not lit at the recorded ignition ({t})");
}
assert!(!rec.missile_is_lit(track, ignition.at as f64 + spec.cold as f64 + f64::from(MissileKind::M48n6.burn_ticks()) + 2.0, spec.cold), "lit past the recorded burnout");
}
/// The physical RM48U strike ejects unlit and slow, then lights at `EJECT_TICKS` where it has
/// reached: no motor voice or flame before, one ignition sound at the hand-over.
#[test]
fn rm48u_strike_ejects_before_ignition_on_its_own_path() {
use super::sites::testland::{Land, serve};
use super::sites::{LaunchSite, SiteClass};
use super::strike::{Estimate, Known, SiteState};
use glam::DVec2;
let land = Land(|_, _| 64);
let mut sim = Sim::new(11);
// A fixed site the strike needs no scouting for; its launches are queued far ahead, so a wave
// boundary does not retire it while the test runs.
let id = sim.sites.add(LaunchSite {
id: 0, side: 0, sector: 0, bearing: 0.0, class: SiteClass::Ramp,
pos: DVec3::new(0.0, 64.0, 400.0), range: 400.0, water: false,
stock: 4, planned: 4, alive: true, next_free: 0, offmap: false,
});
for i in 0..4 {
let site = sim.sites.get(id).unwrap().clone();
let d = sim.make_drone(DroneKind::Shahed, site.muzzle(i), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
sim.sites.job(d.id, id);
sim.queue.push((20_000 + u64::from(i), d));
}
sim.strikes.known.push(Known {
site: id, class: SiteClass::Ramp, obs: Vec::new(),
est: Some(Estimate { pos: DVec2::new(0.0, 400.0), radius: 5.0, fix: true, bearing: 0.0, bearing_err: 0.01, origin: DVec2::ZERO }),
launches: 1, last: 0, state: SiteState::Active, aimed: false,
});
let carrier = sim.id();
let mut sam = Sam::new(carrier, SamKind::S400, DVec3::new(0.0, 64.0, 0.0), 0.0);
sam.missiles = 2;
sam.ground_rounds = 2;
sam.cooldown = 0;
sim.sams.push(sam);
let mut out = Out::default();
sim.order_strike(id, DVec3::ZERO, &mut out).unwrap();
let (mut ground, mut heard_ignition) = (Ground::default(), 0);
let mut saw_voice_before = false;
let mut saw_flame_before = false;
let mut rise = 0.0f64;
let mut all_messages = Vec::new();
for _ in 0..(launch::OPEN_TICKS as usize + strike::EJECT_TICKS as usize + 6) {
serve(&mut ground, &land, sim.tick);
out = Out::default();
sim.step(&mut ground, None, &mut out);
all_messages.append(&mut out.messages);
if let Some(f) = sim.strikes.flights.first() {
let age = sim.tick - f.start;
if age < strike::EJECT_TICKS {
saw_voice_before |= out.engines.iter().any(|e| e.key == 0x7500_0000 + f.id * PARTS);
saw_flame_before |= out.models.iter().any(|m| m.name == "airdef:flame" && m.id == 0x7500_0000 + f.id * PARTS + 1);
rise = rise.max(f.pos.y - f.from.y);
}
if age == strike::EJECT_TICKS {
heard_ignition = out.sounds.iter().filter(|s| s.0 == sound::LAUNCH_S400).count();
}
}
}
assert!(rise > 10.0, "the ejection rose only {rise} blocks");
assert!(!saw_voice_before && !saw_flame_before, "powered effects during the unlit ejection");
assert_eq!(heard_ignition, 1, "exactly one ignition sound at the hand-over: {all_messages:?}");
}
#[test]
+22 -4
View File
@@ -300,6 +300,8 @@ const MISSILE_SPEC_PAC3: MissileSpec = MissileSpec {
turn: 0.3,
boost: 8,
cold: 0,
eject: 0.0,
eject_drag: 0.0,
pk: 0.9,
fuse: 4.0,
life: 240,
@@ -318,6 +320,8 @@ const MISSILE_SPEC_M57E6: MissileSpec = MissileSpec {
turn: 0.32,
boost: 5,
cold: 0,
eject: 0.0,
eject_drag: 0.0,
pk: 0.82,
fuse: 3.0,
life: 160,
@@ -335,7 +339,9 @@ const MISSILE_SPEC_M48N6: MissileSpec = MissileSpec {
accel: 0.5,
turn: 0.22,
boost: 8,
cold: 5,
cold: 20,
eject: 2.4,
eject_drag: 0.07,
pk: 0.9,
fuse: 5.0,
life: 300,
@@ -353,7 +359,9 @@ const MISSILE_SPEC_M77N6: MissileSpec = MissileSpec {
accel: 0.6,
turn: 0.22,
boost: 8,
cold: 5,
cold: 24,
eject: 2.6,
eject_drag: 0.07,
pk: 0.95,
fuse: 6.0,
life: 320,
@@ -371,7 +379,9 @@ const MISSILE_SPEC_IRIST: MissileSpec = MissileSpec {
accel: 0.5,
turn: 0.35,
boost: 6,
cold: 4,
cold: 8,
eject: 1.8,
eject_drag: 0.1,
pk: 0.9,
fuse: 4.0,
life: 260,
@@ -390,6 +400,8 @@ const MISSILE_SPEC_AMRAAM: MissileSpec = MissileSpec {
turn: 0.3,
boost: 6,
cold: 0,
eject: 0.0,
eject_drag: 0.0,
pk: 0.85,
fuse: 4.0,
life: 260,
@@ -408,6 +420,8 @@ const MISSILE_SPEC_M9M317: MissileSpec = MissileSpec {
turn: 0.25,
boost: 8,
cold: 0,
eject: 0.0,
eject_drag: 0.0,
pk: 0.85,
fuse: 5.0,
life: 300,
@@ -425,7 +439,9 @@ const MISSILE_SPEC_M9M338: MissileSpec = MissileSpec {
accel: 0.6,
turn: 0.4,
boost: 4,
cold: 4,
cold: 8,
eject: 1.6,
eject_drag: 0.1,
pk: 0.9,
fuse: 3.0,
life: 160,
@@ -444,6 +460,8 @@ const MISSILE_SPEC_P1SUN: MissileSpec = MissileSpec {
turn: 0.3,
boost: 4,
cold: 0,
eject: 0.0,
eject_drag: 0.0,
pk: 0.8,
fuse: 3.0,
life: 900,
+25 -5
View File
@@ -25,7 +25,16 @@ use glam::DVec2;
/// Minecraft presentation profile; neither dimensions nor timing reproduce a real weapon.
pub const ROUND_MODEL: &str = "airdef:rm48u";
pub const ROUND_SCALE: f64 = 1.5;
pub const EJECT_TICKS: u64 = 5;
/// Ticks the gas throw takes before the motor lights: the same staged cold launch as the
/// interceptor's `cold` (#129), so a physical strike shows the unlit ejection too.
pub const EJECT_TICKS: u64 = 20;
/// The unlit ejection's height at flight age `age`: a gas throw of 2.2 blocks a tick with a
/// linear drag of 0.09 a tick², integrated (game scale, like the interceptor's `eject`).
fn eject_rise(age: u64) -> f64 {
let a = age as f64;
(2.2 * a - 0.045 * a * a).max(0.0)
}
/// Shared live/replay appearance, including the unlit cold-ejection phase.
pub(super) fn round_visual(out: &mut Out, id: u32, pos: DVec3, rot: DQuat, lit: bool) {
@@ -581,10 +590,18 @@ impl Sim {
let end = DVec3::new(f.land.x, below.unwrap_or(f.from.y), f.land.y);
let t = ((now - f.start) as f64 / (f.arrive - f.start).max(1) as f64).clamp(0.0, 1.0);
let age = now.saturating_sub(f.start);
let arc = |t: f64| TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt();
let planned = if f.carrier.is_some() && age < EJECT_TICKS {
f.from + DVec3::Y * (age as f64 * 0.7)
// The unlit gas throw: it rises and slows, no motor yet (#129).
f.from + DVec3::Y * eject_rise(age)
} else if f.carrier.is_some() {
// From where the ejection reached, onto the cruise arc: the plan is continuous
// at ignition, and the flight time counted at the order includes the ejection.
let top = f.from + DVec3::Y * eject_rise(EJECT_TICKS);
let t = ((age - EJECT_TICKS) as f64 / (f.arrive - f.start).saturating_sub(EJECT_TICKS).max(1) as f64).clamp(0.0, 1.0);
top.lerp(end, t) + DVec3::Y * arc(t)
} else {
f.from.lerp(end, t) + DVec3::Y * (TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt())
f.from.lerp(end, t) + DVec3::Y * arc(t)
};
let from = f.pos;
// Time spent waiting for world data must not accumulate into a faster step.
@@ -753,7 +770,9 @@ impl Sim {
self.intel.revealed.push((p.carrier, sam.pos, self.tick));
self.strikes.fired += 1;
self.strikes.next += 1;
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64;
// The counted flight includes the unlit ejection: the missile is honest about
// arriving a second later (#129).
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64 + EJECT_TICKS;
let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, p.site as u64, 7), gauss(self.seed, self.strikes.next as u64, p.site as u64, 8));
self.strikes.flights.push(Flight { id: self.strikes.next, site: p.site, carrier: Some(p.carrier), from: start, pos: start, aim: p.aim,
land: p.aim + DVec2::new(gx, gz) * TUNE.cep, start: self.tick, arrive: self.tick + ticks, snap, hold: 0 });
@@ -790,7 +809,8 @@ impl Sim {
self.strikes.stock -= 1;
self.strikes.fired += 1;
self.strikes.next += 1;
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64;
// The counted flight includes the unlit ejection (#129).
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64 + EJECT_TICKS;
let start = from + DVec3::Y * 1.5;
// Its own scatter at the aim, rolled once here: the flight and its outcome name one point.
let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, id as u64, 7), gauss(self.seed, self.strikes.next as u64, id as u64, 8));
+8 -6
View File
@@ -608,13 +608,15 @@ mod tests {
#[test]
fn recorded_physical_strike_trail_starts_at_ignition_not_at_the_muzzle() {
use crate::sim::record::{Recording, What};
let mut rec = Recording::blank(40);
rec.straight(1, What::StrikeCarrier(77), 10, 30, DVec3::ZERO, DVec3::Z * 20.0);
let ignite = 10.0 + crate::sim::strike::EJECT_TICKS as f64;
let mut rec = Recording::blank(120);
rec.straight(1, What::StrikeCarrier(77), 10, 90, DVec3::ZERO, DVec3::Z * 80.0);
assert!(replay_smoke(&rec, 14.0).is_empty());
let segments = replay_smoke(&rec, 18.0);
assert_eq!(segments.len(), 1);
assert!((segments[0].az - 5.0).abs() < 1e-6);
assert!((segments[0].bz - 8.0).abs() < 1e-6);
assert!(replay_smoke(&rec, (ignite - 2.0).max(30.0)).is_empty(), "no trail before the ignition");
let segments = replay_smoke(&rec, ignite + 12.0);
assert_eq!(segments.len(), 2);
assert!((segments[0].az - 20.0).abs() < 1e-6 && (segments[0].bz - 24.0).abs() < 1e-6, "the trail begins at the recorded ignition");
assert!((segments[1].az - 24.0).abs() < 1e-6 && (segments[1].bz - 32.0).abs() < 1e-6);
}
#[test]
+22 -4
View File
@@ -679,6 +679,8 @@
"turn": 0.3,
"boost": 8,
"cold": 0,
"eject": 0.0,
"eject_drag": 0.0,
"pk": 0.9,
"fuse": 4.0,
"life": 240,
@@ -697,6 +699,8 @@
"turn": 0.32,
"boost": 5,
"cold": 0,
"eject": 0.0,
"eject_drag": 0.0,
"pk": 0.82,
"fuse": 3.0,
"life": 160,
@@ -714,7 +718,9 @@
"accel": 0.5,
"turn": 0.22,
"boost": 8,
"cold": 5,
"cold": 20,
"eject": 2.4,
"eject_drag": 0.07,
"pk": 0.9,
"fuse": 5.0,
"life": 300,
@@ -732,7 +738,9 @@
"accel": 0.6,
"turn": 0.22,
"boost": 8,
"cold": 5,
"cold": 24,
"eject": 2.6,
"eject_drag": 0.07,
"pk": 0.95,
"fuse": 6.0,
"life": 320,
@@ -750,7 +758,9 @@
"accel": 0.5,
"turn": 0.35,
"boost": 6,
"cold": 4,
"cold": 8,
"eject": 1.8,
"eject_drag": 0.1,
"pk": 0.9,
"fuse": 4.0,
"life": 260,
@@ -769,6 +779,8 @@
"turn": 0.3,
"boost": 6,
"cold": 0,
"eject": 0.0,
"eject_drag": 0.0,
"pk": 0.85,
"fuse": 4.0,
"life": 260,
@@ -787,6 +799,8 @@
"turn": 0.25,
"boost": 8,
"cold": 0,
"eject": 0.0,
"eject_drag": 0.0,
"pk": 0.85,
"fuse": 5.0,
"life": 300,
@@ -804,7 +818,9 @@
"accel": 0.6,
"turn": 0.4,
"boost": 4,
"cold": 4,
"cold": 8,
"eject": 1.6,
"eject_drag": 0.1,
"pk": 0.9,
"fuse": 3.0,
"life": 160,
@@ -823,6 +839,8 @@
"turn": 0.3,
"boost": 4,
"cold": 0,
"eject": 0.0,
"eject_drag": 0.0,
"pk": 0.8,
"fuse": 3.0,
"life": 900,
+4 -2
View File
@@ -230,8 +230,8 @@ def validate_specs(data, content, events):
missile_kinds = set()
for i, entry in enumerate(data["missiles"]):
where = f"missiles[{i}]"
_exact(entry, {"kind", "model", "speed", "accel", "turn", "boost", "cold", "pk", "fuse",
"life", "scale", "flame", "rear", "seeker", "launch_sound", "voice"}, where)
_exact(entry, {"kind", "model", "speed", "accel", "turn", "boost", "cold", "eject", "eject_drag",
"pk", "fuse", "life", "scale", "flame", "rear", "seeker", "launch_sound", "voice"}, where)
kind = _text(entry["kind"], f"{where}.kind", r"[A-Z][A-Za-z0-9]*")
if kind in missile_kinds:
raise ValueError(f"{where}: duplicate missile kind {kind}")
@@ -241,6 +241,8 @@ def validate_specs(data, content, events):
_number(entry[field], f"{where}.{field}", 0)
_integer(entry["boost"], f"{where}.boost")
_integer(entry["cold"], f"{where}.cold")
_number(entry["eject"], f"{where}.eject", 0)
_number(entry["eject_drag"], f"{where}.eject_drag", 0)
_integer(entry["life"], f"{where}.life", 1)
_sound(entry["launch_sound"], f"{where}.launch_sound", events)
_exact(entry["voice"], {"event", "gain", "pitch"}, f"{where}.voice")
+2
View File
@@ -167,6 +167,8 @@ def missile_profile(entry):
f" turn: {num(entry['turn'])},",
f" boost: {integer(entry['boost'])},",
f" cold: {integer(entry['cold'])},",
f" eject: {num(entry['eject'])},",
f" eject_drag: {num(entry['eject_drag'])},",
f" pk: {num(entry['pk'])},",
f" fuse: {num(entry['fuse'])},",
f" life: {integer(entry['life'])},",