Files
airdef/airdef-mod/src/sim/record.rs
T
2026-10-30 15:47:03 +03:00

866 lines
37 KiB
Rust

//! Raids are recorded for their replays: every drone's and interceptor's
//! track (a point every other tick, falling wrecks included) and what
//! happened (shot down, by what; hits; systems lost; the Oreshnik's split).
//! A recording starts with the first drone in the sky and ends when the sky
//! has been clear a while; the last few are kept.
use super::*;
/// A system's pose at a moment of a recording.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct SamKey {
/// The recording's tick.
pub at: u32,
/// Relative to the recording's origin.
pos: [f32; 3],
heading: f32,
/// Where the launcher or turret points.
yaw: f32,
pitch: f32,
/// The antenna's turn.
dish: f32,
/// A searchlight's beam: how far it runs free (to 8 blocks), 0 when it is off.
beam: f32,
}
impl SamKey {
fn of(sam: &Sam, origin: DVec3, at: u32) -> Self {
let p = sam.pos - origin;
SamKey {
at,
pos: [p.x as f32, p.y as f32, p.z as f32],
heading: sam.heading as f32,
yaw: sam.yaw as f32,
pitch: sam.pitch as f32,
dish: sam.dish as f32,
beam: if sam.beam_on { ((sam.beam_free / 8.0).round() * 8.0).max(8.0) as f32 } else { 0.0 },
}
}
/// The same pose (to within what could be seen).
fn same(&self, o: &SamKey) -> bool {
let near = |a: f32, b: f32, e: f32| (a - b).abs() < e;
(0..3).all(|i| near(self.pos[i], o.pos[i], 0.01))
&& near(self.heading, o.heading, 0.002)
&& near(self.yaw, o.yaw, 0.002)
&& near(self.pitch, o.pitch, 0.002)
&& near(self.dish, o.dish, 0.002)
&& self.beam == o.beam
}
}
/// A system through a recording: its poses (a new one only when it changed, with the
/// stillness before it marked), and when it was lost.
pub struct SamTrack {
pub id: u32,
pub kind: SamKind,
pub keys: Vec<SamKey>,
/// The recording's tick it was destroyed.
pub lost: Option<u32>,
}
/// `a` turned toward `b` by `f` the short way round.
fn turn(a: f32, b: f32, f: f32) -> f32 {
let tau = std::f32::consts::TAU;
let d = (b - a + std::f32::consts::PI).rem_euclid(tau) - std::f32::consts::PI;
a + d * f
}
impl SamTrack {
/// The pose at `t` (the recording's ticks, fractional); None before the system was first seen.
pub fn pose(&self, t: f64) -> Option<SamKey> {
let first = self.keys.first()?;
if t < first.at as f64 {
return None;
}
let i = self.keys.partition_point(|k| k.at as f64 <= t).saturating_sub(1);
let a = self.keys[i];
let Some(b) = self.keys.get(i + 1) else { return Some(a) };
let f = ((t - a.at as f64) / (b.at - a.at).max(1) as f64).clamp(0.0, 1.0) as f32;
let l = |x: f32, y: f32| x + (y - x) * f;
Some(SamKey {
at: a.at,
pos: [l(a.pos[0], b.pos[0]), l(a.pos[1], b.pos[1]), l(a.pos[2], b.pos[2])],
heading: turn(a.heading, b.heading, f),
yaw: turn(a.yaw, b.yaw, f),
pitch: l(a.pitch, b.pitch),
dish: turn(a.dish, b.dish, f),
beam: a.beam,
})
}
/// The system as it stood at `t`, as a ghost to draw with the ids from `id`.
pub fn ghost(&self, origin: DVec3, t: f64, id: u32) -> Option<Sam> {
let k = self.pose(t)?;
let pos = origin + DVec3::new(k.pos[0] as f64, k.pos[1] as f64, k.pos[2] as f64);
let mut sam = Sam::new(id, self.kind, pos, k.heading as f64);
sam.yaw = k.yaw as f64;
sam.pitch = k.pitch as f64;
sam.dish = k.dish as f64;
sam.beam_on = k.beam > 0.0;
sam.beam_free = k.beam as f64;
sam.health = if self.lost.is_some_and(|l| t >= l as f64) { 0.0 } else { 1.0 };
Some(sam)
}
}
#[cfg(test)]
impl Recording {
/// A recording of `len` ticks with nothing in it (for the planner's tests).
pub(crate) fn blank(len: u32) -> Self {
Recording {
number: 1,
origin: DVec3::ZERO,
len,
tracks: Vec::new(),
index: HashMap::new(),
events: Vec::new(),
tally: Tally::default(),
sams: Vec::new(),
objects: Vec::new(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
}
}
/// A straight track from `from` to `to` over the ticks `first..=last` (every `STEP`).
pub(crate) fn straight(&mut self, id: u32, what: What, first: u32, last: u32, from: DVec3, to: DVec3) {
let n = ((last - first) / STEP).max(1) as usize;
let points = (0..=n).map(|k| {
let p = from.lerp(to, k as f64 / n as f64);
[p.x as f32, p.y as f32, p.z as f32]
});
self.index.insert(id, self.tracks.len());
self.tracks.push(Track { id, what, first, points: points.collect(), fell: None });
}
}
impl Recording {
/// The searchlights' beams at `t` as the recording had them (position and direction from the
/// recorded poses, none for a lost light), never the live state.
pub fn beams(&self, t: f64) -> Vec<lights::Beam> {
self.sam_tracks.iter().filter_map(|st| st.ghost(self.origin, t, st.id)?.spot_beam()).collect()
}
}
/// Ticks between a track's points.
pub const STEP: u32 = 2;
/// The longest recording (10 minutes) and how many are kept.
const LONGEST: u32 = 12_000;
const KEPT: usize = 3;
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum What {
Drone(DroneKind),
/// An interceptor and the system that fired it.
Missile(MissileKind, u32),
}
pub struct Track {
pub id: u32,
pub what: What,
/// The recording's tick of its first point.
pub first: u32,
/// Its points, `STEP` ticks apart, relative to the recording's origin.
points: Vec<[f32; 3]>,
/// When it was shot down (a drone falling from then on).
pub fell: Option<u32>,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum EventKind {
/// A drone shot down: by which system (its index then), with a missile or the guns, and how it ended
/// (rolled once, live; a replay reads it and never rolls).
Kill { drone: u32, kind: DroneKind, sam: u32, sam_kind: SamKind, sam_index: usize, missile: Option<u32>, outcome: Outcome },
/// A drone's warhead went off where it aimed: at an object, a system, or the land.
Hit { drone: u32, kind: DroneKind, power: u32, object: Option<usize>, sam: Option<(SamKind, usize)> },
/// A system destroyed.
SamLost { sam: u32, kind: SamKind, index: usize },
/// The Oreshnik's bus or a block splitting.
Split { drone: u32 },
/// A drone's warhead went off against the land or a building short of its aim.
Crash { drone: u32, kind: DroneKind, power: u32 },
}
#[derive(Clone, Copy, Debug)]
pub struct Event {
pub at: u32,
pub pos: DVec3,
pub kind: EventKind,
}
/// Why interceptors and systems failed, as far as it was counted: only these are known.
#[derive(Clone, Copy, Default, PartialEq, Debug)]
pub struct Tally {
/// Interceptors that reached their fuse and did not kill (the hit chance failed).
pub missed: u32,
/// Interceptors whose life ran out with the target still flying.
pub expired: u32,
/// Interceptors whose target was gone (shot down by another, or lost) before they arrived.
pub orphaned: u32,
/// Times a system lost the track of the target it held, which was still in the sky.
pub tracks_lost: u32,
/// Times a system ran dry and began waiting for a reload truck.
pub ammo_waits: u32,
/// Interceptors lost against the land or a building on their way.
pub blocked: u32,
/// Interceptors that blew up in the air short of land they could not climb over.
pub scrubbed: u32,
}
impl Tally {
pub fn since(self, start: Tally) -> Tally {
Tally {
missed: self.missed.saturating_sub(start.missed),
expired: self.expired.saturating_sub(start.expired),
orphaned: self.orphaned.saturating_sub(start.orphaned),
tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost),
ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits),
blocked: self.blocked.saturating_sub(start.blocked),
scrubbed: self.scrubbed.saturating_sub(start.scrubbed),
}
}
/// The non-zero counts as a line, or nothing.
pub fn text(&self) -> String {
let parts: Vec<String> = [
(self.missed, "промахов по взрывателю"),
(self.expired, "ракет выработали ресурс"),
(self.orphaned, "ракет остались без цели"),
(self.tracks_lost, "потерь трека"),
(self.ammo_waits, "ожиданий БК"),
(self.blocked, "ракет разбились о препятствие"),
(self.scrubbed, "ракет самоуничтожились у рельефа"),
]
.into_iter()
.filter(|p| p.0 > 0)
.map(|p| format!("{} {}", p.0, p.1))
.collect();
parts.join(", ")
}
}
pub struct Recording {
/// Which raid of the session (1, 2, …).
pub number: u32,
pub origin: DVec3,
/// Its length in ticks.
pub len: u32,
pub tracks: Vec<Track>,
index: HashMap<u32, usize>,
pub events: Vec<Event>,
/// The failures counted during the raid.
pub tally: Tally,
/// The systems and objects as they stood: (id, kind, where).
pub sams: Vec<(u32, SamKind, DVec3)>,
pub objects: Vec<DVec3>,
/// Every system's poses through the raid (the drawing of a replay: where it stood, which way
/// it pointed, whether it was still whole).
pub sam_tracks: Vec<SamTrack>,
sam_index: HashMap<u32, usize>,
/// The game's tick number as sampled with the clock: (recording tick, game tick). A replay
/// shows the world's blocks as of this time.
ticks: Vec<(u32, u64)>,
/// The world clock's time of day as (recording tick, day ticks), sampled on every
/// `TIME_STEP`-th tick the clock was known: replays show the sky of the raid, not the current
/// one. A tick the clock was unknown has no sample (a gap), so a late start or a lost clock
/// never shifts later samples; empty when the clock was never known.
times: Vec<(u32, u64)>,
/// Ticks the sky has been clear.
quiet: u32,
}
/// Ticks between the recording's clock samples.
const TIME_STEP: u32 = 20;
impl Track {
/// Where it was at `t` (the recording's ticks, fractional), if it was.
pub fn at(&self, origin: DVec3, t: f64) -> Option<DVec3> {
let k = (t - self.first as f64) / STEP as f64;
if k < 0.0 || self.points.is_empty() || k > (self.points.len() - 1) as f64 {
return None;
}
let i = (k.floor() as usize).min(self.points.len() - 1);
let j = (i + 1).min(self.points.len() - 1);
let f = k - i as f64;
let p = |q: [f32; 3]| DVec3::new(q[0] as f64, q[1] as f64, q[2] as f64);
Some(origin + p(self.points[i]).lerp(p(self.points[j]), f))
}
/// Which way it was flying at `t`.
pub fn dir(&self, origin: DVec3, t: f64) -> DVec3 {
let a = self.at(origin, t - STEP as f64).or_else(|| self.at(origin, t));
let b = self.at(origin, t + STEP as f64).or_else(|| self.at(origin, t));
match (a, b) {
(Some(a), Some(b)) => (b - a).normalize_or(DVec3::NEG_Z),
_ => DVec3::NEG_Z,
}
}
/// Its `i`-th point (every `STEP` ticks from `first`), if it has one.
pub fn sample(&self, origin: DVec3, i: usize) -> Option<DVec3> {
self.points.get(i).map(|q| origin + DVec3::new(q[0] as f64, q[1] as f64, q[2] as f64))
}
/// The tick of its last point.
pub fn last(&self) -> u32 {
self.first + (self.points.len().max(1) as u32 - 1) * STEP
}
/// Its points, every `every`-th, up to tick `t`.
pub fn path(&self, origin: DVec3, every: usize, t: f64) -> Vec<DVec3> {
let p = |q: &[f32; 3]| origin + DVec3::new(q[0] as f64, q[1] as f64, q[2] as f64);
let upto = ((t - self.first as f64) / STEP as f64).floor();
if upto < 0.0 {
return Vec::new();
}
let upto = (upto as usize).min(self.points.len().saturating_sub(1));
let mut out: Vec<DVec3> = self.points[..=upto].iter().step_by(every.max(1)).map(p).collect();
if upto % every.max(1) != 0 {
out.push(p(&self.points[upto]));
}
out
}
}
/// A fire as a replay shows it: what the events lit and kept alight, merged as the live fires are.
#[derive(Clone, Copy, Debug)]
pub struct ReplayFire {
pub at: DVec3,
/// The recording's tick it was lit.
pub start: u32,
pub life: u32,
pub size: f32,
/// The event that lit it (the models' ids).
pub key: usize,
}
/// The look of an interception by the drone's warhead: (fireball radius, ticks it burns, loudness).
/// A bare decoy pops; a big warhead lights the sky. The same in the live raid and its replay.
pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
let (size, life, gain, _) = outcome::look(kind, Outcome::DisabledFalling);
(size, life, gain)
}
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
pub fn blast_spec(kind: EventKind) -> (f32, u32) {
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
const LIFE: [u32; 5] = [11, 11, 18, 22, 26];
match kind {
EventKind::Kill { kind, outcome, .. } => {
let (size, life, _, _) = outcome::look(kind, outcome);
(size, life)
}
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
let p = (power as usize).min(4);
(SIZE[p], LIFE[p] * 9)
}
EventKind::SamLost { .. } => (8.0, 18 * 9),
EventKind::Split { .. } => (2.0, 7 * 9),
}
}
/// The blasts of the recording's events burning at `t` (its ticks, fractional): the same
/// `Blast`s the live simulation keeps, with `start` in the recording's ticks and the event's
/// own id range. A pure function of `t`, so a seek or a pause shows the same frame.
pub fn replay_blasts(rec: &Recording, t: f64) -> Vec<Blast> {
rec.events
.iter()
.enumerate()
.filter_map(|(k, e)| {
let (size, life) = blast_spec(e.kind);
let age = t - e.at as f64;
(0.0..life as f64).contains(&age).then_some(Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life })
})
.collect()
}
/// The fires burning at `t` in the recording: events within 6 blocks of a fire still burning
/// feed it (the greater size, the longer life), as `Sim::ignite` does; those after it died light
/// a new one. A pure function of `t`.
pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
let mut fires: Vec<ReplayFire> = Vec::new();
for (k, e) in rec.events.iter().enumerate() {
if e.at as f64 > t {
break;
}
let power = match e.kind {
EventKind::Crash { power, .. } => power,
EventKind::Hit { power, .. } => power.max(1),
EventKind::SamLost { .. } => 2,
_ => continue,
};
let (size, life) = (fires::size_for(power), fires::life_for(power));
let alive = |f: &ReplayFire| e.at < f.start + f.life;
if let Some(f) = fires.iter_mut().find(|f| alive(f) && f.at.distance(e.pos) < 6.0) {
f.size = f.size.max(size);
f.life = f.life.max(e.at - f.start + life);
} else {
fires.push(ReplayFire { at: e.pos, start: e.at, life, size, key: k });
}
}
fires.retain(|f| t < (f.start + f.life) as f64);
fires
}
impl Recording {
fn new(number: u32, sim: &Sim) -> Self {
let origin = sim.targets.first().copied().or_else(|| sim.sams.first().map(|s| s.pos)).unwrap_or(DVec3::ZERO);
Recording {
number,
origin: origin.floor(),
len: 0,
tracks: Vec::new(),
index: HashMap::new(),
events: Vec::new(),
tally: Tally::default(),
sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(),
objects: sim.targets.clone(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
}
}
/// Notes the systems' poses at the recording's tick `now`: a pose only when it changed, with
/// the pose it held marked before it so a replay doesn't glide through the stillness.
fn note_sams(&mut self, sams: &[Sam], now: u32) {
for sam in sams {
let key = SamKey::of(sam, self.origin, now);
let i = *self.sam_index.entry(sam.id).or_insert_with(|| {
self.sam_tracks.push(SamTrack { id: sam.id, kind: sam.kind, keys: Vec::new(), lost: None });
self.sam_tracks.len() - 1
});
let track = &mut self.sam_tracks[i];
match track.keys.last().copied() {
None => track.keys.push(key),
Some(last) if last.same(&key) => {}
Some(last) => {
if last.at + STEP < now {
track.keys.push(SamKey { at: now - STEP, ..last });
}
track.keys.push(key);
}
}
}
}
/// The game's tick at the recording's tick `t` (fractional), if it was known: the samples run
/// on at the pace of the ticks between them (a pause or a gap is a step).
pub fn world_tick(&self, t: f64) -> Option<u64> {
let t = t.max(0.0);
let i = self.ticks.partition_point(|s| s.0 as f64 <= t).checked_sub(1).or(if self.ticks.is_empty() { None } else { Some(0) })?;
let (ta, a) = self.ticks[i];
let ahead = (t - ta as f64).max(0.0);
Some(match self.ticks.get(i + 1) {
Some(&(tb, b)) if b >= a && b - a <= 2 * (tb - ta).max(1) as u64 => a + (ahead.min((tb - ta) as f64) * (b - a) as f64 / (tb - ta).max(1) as f64) as u64,
_ => a + ahead as u64,
})
}
/// The game tick the recording began at.
pub fn first_tick(&self) -> Option<u64> {
self.ticks.first().map(|s| s.1)
}
fn point(&mut self, id: u32, what: What, pos: DVec3) {
let rel = pos - self.origin;
let q = [rel.x as f32, rel.y as f32, rel.z as f32];
let now = self.len;
match self.index.get(&id) {
Some(&i) => self.tracks[i].points.push(q),
None => {
self.index.insert(id, self.tracks.len());
self.tracks.push(Track { id, what, first: now, points: vec![q], fell: None });
}
}
}
/// The time of day (ticks, 24000 a day) the world showed at the recording's tick `t`: the
/// clock as sampled, run on between samples at the pace it ran (a jump is a step), or None
/// when the clock was not known (the replay then shows the world's own time).
pub fn day_time(&self, t: f64) -> Option<u64> {
let t = t.max(0.0);
// The last sample at or before `t`; none before the first one means unknown.
let i = self.times.partition_point(|s| s.0 as f64 <= t).checked_sub(1)?;
let (ta, a) = self.times[i];
match self.times.get(i + 1) {
// Neighbouring samples with no gap between: run on at the pace between them.
Some(&(tb, b)) if tb - ta <= TIME_STEP && b >= a && b - a <= 2 * TIME_STEP as u64 => {
let f = ((t - ta as f64) / (tb - ta) as f64).clamp(0.0, 1.0);
Some(a + ((b - a) as f64 * f) as u64)
}
_ => Some(a),
}
}
pub fn track(&self, id: u32) -> Option<&Track> {
self.index.get(&id).map(|&i| &self.tracks[i])
}
/// The system with this id as it stood.
pub fn sam(&self, id: u32) -> Option<(SamKind, DVec3)> {
self.sams.iter().find(|s| s.0 == id).map(|s| (s.1, s.2))
}
}
impl Sim {
/// The recording of raid `number`, while it is kept.
pub fn replay(&self, number: u32) -> Option<&Recording> {
self.replays.iter().find(|r| r.number == number)
}
}
impl Sim {
/// Counts something in the session's tally and in the recording going on.
pub(super) fn note(&mut self, f: impl Fn(&mut Tally)) {
f(&mut self.tally);
if let Some(r) = &mut self.rec {
f(&mut r.tally);
}
}
/// Something worth a replay happened.
pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) {
match kind {
EventKind::Crash { power, .. } => self.ignite(pos, power, None),
EventKind::Hit { power, sam, .. } => {
let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id);
self.ignite(pos, power.max(1), host)
}
EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)),
_ => {}
}
if let Some(r) = &mut self.rec {
let at = r.len;
if let EventKind::Kill { drone, .. } = kind
&& let Some(&i) = r.index.get(&drone)
{
r.tracks[i].fell = Some(at);
}
if let EventKind::SamLost { sam, .. } = kind
&& let Some(&i) = r.sam_index.get(&sam)
{
r.sam_tracks[i].lost.get_or_insert(at);
}
r.events.push(Event { at, pos, kind });
}
}
/// Once a tick: starts a recording with the first drone, adds the tracks' points, ends it
/// when the sky has been clear 5 s.
pub(super) fn record(&mut self) {
// Wrecks still falling (far off they fall a long while) don't keep it going.
let busy = self.drones.iter().any(|d| d.alive()) || !self.missiles.is_empty() || !self.queue.is_empty();
if self.rec.is_none() {
// It starts with the first drone in the sky (not with a raid's drones still waiting).
if !self.drones.iter().any(|d| d.alive()) {
return;
}
self.recordings += 1;
self.rec = Some(Recording::new(self.recordings, self));
}
let Some(r) = &mut self.rec else { return };
if r.len % TIME_STEP == 0 {
if let Some(dt) = self.day_time {
r.times.push((r.len, dt));
}
r.ticks.push((r.len, self.engine_tick));
}
if r.len % STEP == 0 {
let now = r.len;
r.note_sams(&self.sams, now);
for d in &self.drones {
r.point(d.id, What::Drone(d.kind), d.pos);
}
for m in &self.missiles {
r.point(m.id, What::Missile(m.kind, m.sam), m.pos);
}
}
r.len += 1;
r.quiet = if busy { 0 } else { r.quiet + 1 };
if r.quiet >= 100 || r.len >= LONGEST {
let mut done = self.rec.take().unwrap();
done.len = done.len.saturating_sub(done.quiet.saturating_sub(40));
if !done.events.is_empty() {
self.replays.push(done);
if self.replays.len() > KEPT {
self.replays.remove(0);
}
}
}
}
/// Draws the recording's drones and interceptors as they were at `t`, as ghosts: ids from `base`.
pub fn draw_ghosts(&self, rec: &Recording, t: f64, base: u32, out: &mut Out) {
let base_id = base;
for (k, tr) in rec.tracks.iter().enumerate() {
let Some(pos) = tr.at(rec.origin, t) else { continue };
let dir = tr.dir(rec.origin, t);
let id = base + k as u32 * PARTS;
match tr.what {
What::Drone(kind) => {
let spec = kind.spec();
let s = spec.scale;
let falling = tr.fell.is_some_and(|f| t >= f as f64);
let mut rot = look(dir, 0.0);
if falling {
let spin = (t - tr.fell.unwrap_or(0) as f64) * 0.35;
rot = rot * DQuat::from_rotation_z(spin) * DQuat::from_rotation_x(-0.6);
out.model(id, spec.model, pos, rot, s, false);
out.model(id + 1, "airdef:flame", pos, rot, 0.6 + 0.2 * ((t * 1.7).sin() + 1.0), true);
continue;
}
if !matches!(kind, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) {
out.model(id, spec.model, pos, rot, s, false);
}
match spec.engine {
Engine::Prop(back, size) => {
let at = pos + rot * DVec3::new(0.0, 0.0, back * s);
out.model(id + 1, "airdef:shahed_prop", at, rot * DQuat::from_rotation_z(t * 1.1), s * size, false);
}
Engine::Jet(up, back, size) => {
out.model(id + 1, "airdef:geran3_flame", pos + rot * DVec3::new(0.0, up * s, back * s), rot, s * size, true);
}
Engine::Rocket(back, size) => {
if matches!(kind, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) {
out.model(id + 1, "airdef:reentry", pos, rot, s, true);
} else if spec.ballistic.is_none() || t - (tr.first as f64) < 60.0 {
out.model(id + 1, "airdef:flame", pos + rot * DVec3::new(0.0, 0.0, back * s + size), rot, size, true);
} else {
out.model(id + 1, "airdef:reentry", pos, rot, s * 0.6, true);
}
}
}
}
What::Missile(kind, _) => {
let spec = kind.spec();
let rot = look(dir, 0.0) * DQuat::from_rotation_z(t * 0.3);
out.model(id, spec.model, pos, rot, spec.scale, false);
if t - tr.first as f64 > spec.cold as f64 && spec.flame > 0.0 {
let back = (spec.rear - 8.0) / 16.0 * spec.scale + spec.flame;
out.model(id + 1, "airdef:flame", pos + rot * DVec3::new(0.0, 0.0, back), rot, spec.flame, true);
}
}
}
}
// The air-defence systems as they stood: where, which way, the launcher or turret where it
// pointed, a wreck once lost.
let mut rng = Rng(1);
for (k, st) in rec.sam_tracks.iter().enumerate() {
if let Some(ghost) = st.ghost(rec.origin, t, base + 0x0040_0000 + k as u32 * PARTS) {
units::draw_sam_model(&ghost, &mut rng, out);
}
}
// The protected objects as marked in the raid (struck ones shrink), not the live ones.
for (i, p) in rec.objects.iter().enumerate() {
let struck = rec.events.iter().any(|e| e.at as f64 <= t && matches!(e.kind, EventKind::Hit { object: Some(o), .. } if o == i));
let bob = (t * 0.1 + i as f64).sin() * 0.25;
let rot = DQuat::from_rotation_y(t * 0.08);
out.model(base + 0x00e0_0000 + i as u32 * PARTS, "airdef:target", *p + DVec3::Y * (1.6 + bob), rot, if struck { 0.4 } else { 0.8 }, true);
}
// Fires: what the events left burning (the flames dim and the smoke thickens with time),
// out when their life ends.
for f in replay_fires(rec, t) {
let age = t - f.start as f64;
let (flame, smoke) = fires::replay_fire_of(f.size, f.life, age);
let id = base + 0x00c0_0000 + f.key as u32 * PARTS;
if flame > 0.05 {
let wob = 1.0 + 0.15 * (age * 0.9 + f.key as f64).sin();
out.model(id, "airdef:flame", f.at + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true);
}
if smoke > 0.1 && (age as u64) % 3 == 0 {
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
}
}
// The pieces a breakup or an air detonation threw off, from the recorded outcome (never a new roll): the same
// pieces the live raid made, moved by the same flight, a pure function of the time.
for (k, e) in rec.events.iter().enumerate() {
let EventKind::Kill { drone, outcome, .. } = e.kind else { continue };
let age = t - e.at as f64;
if outcome == Outcome::DisabledFalling || !(0.0..outcome::PIECE_LIFE as f64).contains(&age) {
continue;
}
let base = rec
.track(drone)
.and_then(|tr| Some((tr.at(rec.origin, e.at as f64)? - tr.at(rec.origin, e.at as f64 - STEP as f64)?) / STEP as f64))
.unwrap_or(DVec3::ZERO);
let seed = (k as u64).wrapping_mul(0x2545_f491_4f6c_dd1d) ^ e.at as u64;
for (n, p) in outcome::pieces(seed, outcome, base).iter().enumerate() {
if age as u32 >= p.life {
continue;
}
let at = outcome::piece_at(e.pos, p, age as u32);
let id = base_id + 0x0060_0000 + (k as u32 * 16 + n as u32) * PARTS;
out.model(id, "airdef:debris", at, outcome::piece_rot(p, age as u32), p.scale, false);
if p.burns && (age as u32) % 3 == 0 {
out.particles("minecraft:large_smoke", at, 0.1, DVec3::new(0.0, 0.03, 0.0), 0.01, 1);
}
}
}
// Blasts: with the shader effects on, the post pass and the HD lights draw them from
// `replay_blasts` (the same as the live ones); the models are the fallback without them.
if self.shader {
return;
}
for (k, e) in rec.events.iter().enumerate() {
let age = t - e.at as f64;
if !(0.0..30.0).contains(&age) {
continue;
}
let (size, _) = blast_spec(e.kind);
let size = size as f64;
let f = age / 30.0;
let s = size * (f * 4.0).min(1.0) * (1.0 - f * f);
let id = base + 0x0080_0000 + k as u32 * PARTS;
let spin = DQuat::from_rotation_y(age * 0.05);
out.model(id, "airdef:fireball", e.pos + DVec3::Y * age * 0.04, spin, s.max(0.01), true);
if age < 4.0 {
out.model(id + 1, "airdef:flash", e.pos, spin, size * 0.6, true);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sam_at(x: f64, heading: f64, yaw: f64) -> Sam {
let mut s = Sam::new(7, SamKind::S400, DVec3::new(x, 64.0, 0.0), heading);
s.yaw = yaw;
s
}
fn recording() -> Recording {
Recording {
number: 1,
origin: DVec3::ZERO,
len: 0,
tracks: Vec::new(),
index: HashMap::new(),
events: Vec::new(),
tally: Tally::default(),
sams: Vec::new(),
objects: Vec::new(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
}
}
#[test]
fn a_system_keeps_its_pose_through_stillness_and_moves_when_it_moved() {
let mut r = recording();
// Still for 20 ticks, then the turret swings and the vehicle drives.
for now in (0..=20).step_by(STEP as usize) {
r.note_sams(&[sam_at(0.0, 0.0, 0.0)], now);
}
assert_eq!(r.sam_tracks[0].keys.len(), 1, "stillness is one key");
r.note_sams(&[sam_at(4.0, 0.0, 1.0)], 22);
let t = &r.sam_tracks[0];
let pose = |tick: f64| t.pose(tick).unwrap();
// Held at the old pose until just before the change, not gliding over the stillness.
assert!(pose(10.0).pos[0].abs() < 1e-6 && pose(10.0).yaw.abs() < 1e-6);
assert!(pose(20.0).pos[0].abs() < 1e-6);
// Half way through the move.
assert!((pose(21.0).pos[0] - 2.0).abs() < 1e-4 && (pose(21.0).yaw - 0.5).abs() < 1e-4);
assert!((pose(100.0).pos[0] - 4.0).abs() < 1e-6);
}
// #76: a replay's searchlights point where the recording had them, from the recorded pose.
#[test]
fn a_replay_has_the_recorded_searchlight_beams() {
let mut r = recording();
let mut light = Sam::new(9, SamKind::Light, DVec3::new(10.0, 64.0, 0.0), 0.0);
light.beam_on = true;
light.beam_free = 300.0;
light.yaw = 1.0;
light.pitch = 0.8;
r.note_sams(&[light], 0);
// Swung round and switched off later.
let mut later = Sam::new(9, SamKind::Light, DVec3::new(10.0, 64.0, 0.0), 0.0);
later.yaw = 2.0;
later.pitch = 0.8;
r.note_sams(&[later], 100);
assert!(r.beams(-1.0).is_empty(), "nothing before it was seen");
let b = r.beams(0.0);
assert_eq!(b.len(), 1);
assert_eq!(b[0].id, 9);
assert!((b[0].free - 304.0).abs() < 1e-6 || (b[0].free - 296.0).abs() < 1e-6, "free {}", b[0].free);
assert!(b[0].dir.y > 0.5, "pointing up: {:?}", b[0].dir);
assert!(r.beams(100.0).is_empty(), "switched off");
r.sam_tracks[0].lost = Some(50);
assert!(r.beams(10.0).len() == 1 && r.beams(60.0).is_empty(), "a lost light has no beam");
}
#[test]
fn a_system_first_seen_later_is_not_drawn_before() {
let mut r = recording();
r.note_sams(&[sam_at(0.0, 0.0, 0.0)], 40);
assert!(r.sam_tracks[0].pose(10.0).is_none());
assert!(r.sam_tracks[0].pose(40.0).is_some());
}
#[test]
fn angles_turn_the_short_way_and_a_lost_system_is_a_wreck() {
let mut r = recording();
r.note_sams(&[sam_at(0.0, 0.0, 6.2)], 0);
r.note_sams(&[sam_at(0.0, 0.0, 0.1)], 2);
let t = &mut r.sam_tracks[0];
let mid = t.pose(1.0).unwrap().yaw;
assert!(!(0.2..6.0).contains(&mid), "through zero, not back round: {mid}");
assert!(t.ghost(DVec3::ZERO, 1.0, 0).unwrap().alive());
t.lost = Some(30);
assert!(t.ghost(DVec3::ZERO, 29.0, 0).unwrap().alive());
assert!(!t.ghost(DVec3::ZERO, 31.0, 0).unwrap().alive());
}
#[test]
fn the_games_tick_is_known_through_the_recording() {
let mut r = recording();
assert_eq!(r.world_tick(5.0), None);
r.ticks = vec![(0, 1000), (20, 1020), (40, 1040), (60, 1100)];
assert_eq!(r.first_tick(), Some(1000));
assert_eq!(r.world_tick(0.0), Some(1000));
assert_eq!(r.world_tick(10.0), Some(1010));
assert_eq!(r.world_tick(30.0), Some(1030));
// A jump of the game's ticks between samples (the recording paused or a gap) is a step.
assert_eq!(r.world_tick(50.0), Some(1040 + 10));
assert_eq!(r.world_tick(70.0), Some(1100 + 10));
}
// #97: the kill's outcome is a recorded fact: a replay draws the pieces of that outcome and rolls nothing;
// an event without one (an old recording) is the plain fall it always was.
#[test]
fn a_replay_shows_the_recorded_outcome() {
let mut sim = Sim::new(5);
sim.rec = Some(Recording::new(1, &sim));
let sam = sim.id();
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
let at = sim.drones[0].pos;
let live = sim.shoot_down_by(0, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut Out::default()).unwrap();
let rec = sim.rec.take().unwrap();
let recorded = rec.events.iter().find_map(|e| if let EventKind::Kill { outcome, .. } = e.kind { Some(outcome) } else { None });
assert_eq!(recorded, Some(live), "the live outcome is what was recorded");
let pieces = |rec: &Recording, t: f64| {
let mut out = Out::default();
sim.draw_ghosts(rec, t, 0x5000_0000, &mut out);
out.models.iter().filter(|m| m.name == "airdef:debris").count()
};
let at_tick = rec.events[0].at as f64;
// The same frame twice is the same (a pure function of the time), and the pieces are the outcome's.
let mut forced = Recording { events: rec.events.clone(), ..Recording::blank(200) };
for (outcome, want) in [(Outcome::AirDetonation, true), (Outcome::Breakup, true), (Outcome::DisabledFalling, false)] {
if let EventKind::Kill { outcome: o, .. } = &mut forced.events[0].kind {
*o = outcome;
}
assert_eq!(pieces(&forced, at_tick + 5.0), pieces(&forced, at_tick + 5.0));
assert_eq!(pieces(&forced, at_tick + 5.0) > 0, want, "{outcome:?}");
assert_eq!(pieces(&forced, at_tick + f64::from(outcome::PIECE_LIFE) + 1.0), 0, "the pieces are gone in time");
}
// Its flash and sound are the outcome's too.
let spec = |o| {
let mut e = rec.events[0];
if let EventKind::Kill { outcome, .. } = &mut e.kind {
*outcome = o;
}
blast_spec(e.kind)
};
assert!(spec(Outcome::AirDetonation).0 > spec(Outcome::DisabledFalling).0);
assert_eq!(spec(Outcome::default()).0, kill_look(DroneKind::Shahed).0, "an old recording looks as it did");
}
}