545d4777e8
build / mod (push) Successful in 3m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
866 lines
37 KiB
Rust
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");
|
|
}
|
|
}
|