diff --git a/README.md b/README.md index 080a2ec..bf8000e 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ python3 tools/airdef.py play # запустить (аргументы игр (`tools/gen_sounds.py`, нужны numpy и ffmpeg), собирает и ставит мод. Сгенерированные файлы лежат в репозитории, так что для обычной сборки нужен только Rust. -CI: `.gitea/workflows/build.yml` на каждый пуш собирает мод и игру для Linux, а пуш в `main` публикует релиз -`build-N`; `.gitea/workflows/desktop.yml` (запуск вручную с тегом релиза) собирает игру для macOS и Windows на -раннерах с метками `macos-latest` / `windows-latest` и дописывает архивы в релиз. +CI: `.gitea/workflows/build.yml` на каждый пуш собирает только мод (`airdef.hmod`), а пуш в `main` публикует его +релизом `build-N`. Игру-форк собирают вручную: `.gitea/workflows/game.yml` (Linux) и `.gitea/workflows/desktop.yml` +(macOS и Windows, на раннерах с метками `macos-latest` / `windows-latest`); оба запускаются из Actions с тегом +готового релиза и дописывают свои архивы в него. ## Как играть @@ -130,7 +131,7 @@ API мода `render.set-lights` (до 512, плавно интерполиру перехватчиков (жёлтые), сбития (x), прилёты, шкала времени с событиями; время идёт само, колесо мыши перематывает; - **повтор момента** — клик по событию в списке: «призраки» дронов и ракет снова летят в мире, камера идёт за целью, потом за перехватчиком (или смотрит из пушки, со стороны на прилёт), в момент сбития время замедляется; -- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — скорость (×0.5–×16); +- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — перемотка, стрелки — скорость; - **лучшие моменты** — нарезка до 8 самых интересных событий (перехват баллистики и гиперзвука, поражения объектов, потерянные комплексы, разделение «Орешника») с титрами и замедлением; колесо листает моменты. diff --git a/airdef-mod/shaders/effects.wgsl b/airdef-mod/shaders/effects.wgsl index 1a746cc..876862c 100644 --- a/airdef-mod/shaders/effects.wgsl +++ b/airdef-mod/shaders/effects.wgsl @@ -197,7 +197,10 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { if age < 0.0 || age > 10.0 { continue; } let p = project(a.xyz); if p.z <= 0.5 { continue; } - let visible = select(0.25, 1.0, scene_distance(p.xy) > p.z - a.w); + // Ray distance on both sides: `p.z` is the depth along the view axis, which is shorter + // than the distance along the ray towards the screen edge. + let dist = length(a.xyz - frame.camera.xyz); + let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w); let f = exp(-age * 0.7) * min(a.w / 4.0, 2.5) * visible; let r = screen_radius(a.xyz, a.w * 2.5) + 20.0; let dd = length(px - p.xy * frame.resolution.xy) / r; @@ -211,7 +214,7 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { let b = frame.params[LIGHT_BASE + 2 * j + 1]; let p = project(a.xyz); if p.z <= 0.3 { continue; } - if scene_distance(p.xy) < p.z - a.w * 1.5 { continue; } + if scene_distance(p.xy) < length(a.xyz - frame.camera.xyz) - a.w * 1.5 { continue; } let r = screen_radius(a.xyz, a.w) + 3.0; let dd = length(px - p.xy * frame.resolution.xy) / r; let flicker = 0.85 + 0.15 * noise3(vec3(a.xyz * 0.7 + vec3(t_now * 1.3))); diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 0a2936b..2a80829 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -85,8 +85,17 @@ struct AirDef { menu: Option, /// Where the player is, for the sounds' delay and far variants. listener: DVec3, - /// Sounds on their way to the player at the speed of sound: (tick, event, at, pitch). - pending: Vec<(u64, String, DVec3, f32)>, + /// Sounds on their way to the player at the speed of sound. + pending: Vec, +} + +/// A sound heard later: when, what, where, and how loud and high it was. +struct PendingSound { + due: u64, + name: String, + at: DVec3, + volume: f32, + pitch: f32, } /// Blocks a tick the sound travels (343 m/s). @@ -310,7 +319,8 @@ impl AirDef { let size = b.size; let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp(); let flash = (-age * 0.35).exp() * 4.0; - let id = 0x6100_0000 ^ (b.start as u32).wrapping_mul(31) ^ (b.at.x as i32 as u32).wrapping_mul(7); + // Its own id range: the models' and the systems' ids are small numbers. + let id = 0x6100_0000 | (b.id & 0x00ff_ffff); add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0); } for s in &self.sim.sams { @@ -355,7 +365,14 @@ impl AirDef { let mut p = vec![0.0f32; 4]; p[0] = self.sim.tick as f32; p[3] = if self.hd { 1.0 } else { 0.0 }; - let blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect(); + let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect(); + // The shader composites the volumes front to back with one shared transmittance, so + // the nearest to the player goes first. This is exact for volumes that do not + // overlap; where two intersect, no order by centre is right along every ray. + if let Some(pl) = player() { + let e = eye(&pl); + blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e))); + } p[1] = blasts.len() as f32; for b in &blasts { p.extend([b.at.x as f32, b.at.y as f32, b.at.z as f32, b.size]); @@ -444,7 +461,7 @@ impl AirDef { if delay == 0 { render::play_sound(&name, at.x, at.y, at.z, volume, pitch); } else if self.pending.len() < 256 { - self.pending.push((now + delay, name, at, pitch)); + self.pending.push(PendingSound { due: now + delay, name, at, volume, pitch }); } } else { render::play_sound(event, at.x, at.y, at.z, volume, pitch); @@ -452,9 +469,9 @@ impl AirDef { } let mut i = 0; while i < self.pending.len() { - if self.pending[i].0 <= now { - let (_, name, at, pitch) = self.pending.swap_remove(i); - render::play_sound(&name, at.x, at.y, at.z, 1.0, pitch); + if self.pending[i].due <= now { + let s = self.pending.swap_remove(i); + render::play_sound(&s.name, s.at.x, s.at.y, s.at.z, s.volume, s.pitch); } else { i += 1; } @@ -651,6 +668,12 @@ impl AirDef { self.monitor = None; self.grid = false; self.grid_ids.clear(); + // The last world's transient state: its sounds were due on its own + // clock, its picked unit may have another meaning in this one, and + // its models would stay until the next full model list. + self.pending.clear(); + self.moving = None; + render::set_models(&[]); self.far = true; self.world = Some(world); self.load(); diff --git a/airdef-mod/src/monitor.rs b/airdef-mod/src/monitor.rs index 494367f..5fff412 100644 --- a/airdef-mod/src/monitor.rs +++ b/airdef-mod/src/monitor.rs @@ -25,11 +25,22 @@ fn fit(t: String, w: f32) -> String { if render::text_width(&t) <= w { return t; } - let mut cut: String = t; - while !cut.is_empty() && render::text_width(&format!("{cut}…")) > w { - cut.pop(); + // The width grows with the prefix: bisect over its char starts, so a long + // label costs a few host calls rather than one per dropped char. + let starts: Vec = t.char_indices().map(|(i, _)| i).collect(); + let (mut lo, mut hi) = (0, starts.len()); + while lo < hi { + let mid = (lo + hi) / 2; + if render::text_width(&format!("{}…", &t[..starts[mid]])) <= w { + lo = mid + 1; + } else { + hi = mid; + } } - format!("{cut}…") + if lo == 0 { + return "…".into(); + } + format!("{}…", &t[..starts[lo - 1]]) } fn argb(rgb: u32, a: u32) -> u32 { @@ -101,7 +112,13 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec 0.0 => r, + _ => spec.sensor, + }; + let scale = if reach > 0.0 { rr as f64 / (reach * 1.25) } else { 0.0 }; let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU; for k in 0..6 { let a1 = sweep - k as f32 * 0.09; @@ -115,11 +132,11 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec reach * 1.25 { + if reach <= 0.0 || flat > reach * 1.25 { continue; } seen += 1; - let k = (rr as f64 / (reach * 1.25)) as f32; + let k = scale as f32; let (bx, by) = (rx + rel.x as f32 * k, ry + rel.z as f32 * k); let allowed = sam.roles & d.kind.spec().class.bit() != 0; let color = if Some(d.id) == target.map(|t| t.id) { diff --git a/airdef-mod/src/panel.rs b/airdef-mod/src/panel.rs index 2b70d9e..15491bc 100644 --- a/airdef-mod/src/panel.rs +++ b/airdef-mod/src/panel.rs @@ -10,6 +10,8 @@ const CYAN: u32 = 0x9FE0FF; const GREY: u32 = 0xB0B0B0; const YELLOW: u32 = 0xFFE070; const GREEN: u32 = 0x80FF90; +/// The most lines the summary holds (the HUD's room). +const MAX_LINES: usize = 16; /// Metres (a block is a metre) as a short distance. pub fn distance(m: f64) -> String { @@ -77,7 +79,15 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec { if !sim.missiles.is_empty() { lines.push(line(format!("Перехватчиков в полёте: {}", sim.missiles.len()), YELLOW)); } - for sam in sim.sams.iter().take(8) { + // The score is the last line and always shown: the unit list gets what + // room is left, and cuts its own tail. + let room = (MAX_LINES - 1).saturating_sub(lines.len()); + let total = sim.sams.len(); + let mut shown = total.min(8); + if shown + usize::from(total > 8) > room { + shown = room.saturating_sub(1); + } + for sam in sim.sams.iter().take(shown) { let mut text = if sam.kind.armed() { format!("{} [{}]", sam.status(), crate::sim::roles_text(sam.roles)) } else { @@ -91,15 +101,16 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec { } lines.push(line(text, CYAN)); } - if sim.sams.len() > 8 { - lines.push(line(format!("…и ещё {} комплексов", sim.sams.len() - 8), CYAN)); + if total > shown && room > 0 { + lines.push(line(format!("…и ещё {} комплексов", total - shown), CYAN)); } + // Room for the score whatever came before. + lines.truncate(MAX_LINES - 1); let real = sim.launched.saturating_sub(0); let rate = if real > 0 { sim.kills as f64 * 100.0 / real as f64 } else { 0.0 }; lines.push(line( format!("Сбито {} из {} ({rate:.0}%) · прорвалось {}", sim.kills, sim.launched, sim.hits), GREY, )); - lines.truncate(16); lines } diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 230fbe6..7e91d24 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -5,7 +5,8 @@ //! behind the drone, then behind the interceptor or from the side — slowing //! time round the moment itself. use crate::panel::distance; -use crate::sim::record::{EventKind, Recording, What}; +use crate::sim::record::{EventKind, Recording, STEP, What}; +use std::cell::Cell; use crate::sim::{Class, DroneKind, Out, Sim}; use glam::DVec3; use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape}; @@ -55,6 +56,8 @@ pub struct Player { /// Ticks the controls stay on screen. help: u32, pub done: bool, + /// The map's area, worked out on the first map frame. + area: Cell>, } /// What the replay's keys do. @@ -201,6 +204,7 @@ impl Player { }; let _ = sim; Player { + area: Cell::new(None), rec, mode, t: start, @@ -581,34 +585,16 @@ impl Player { fn map(&self, rec: &Recording, gw: f32, gh: f32) -> Vec { let mut s = vec![fill(0.0, 0.0, gw, gh, 0xF0060A08)]; let o = rec.origin; - // The area: everything that flew, but not far beyond the systems. - let mut lo = (f64::MAX, f64::MAX); - let mut hi = (f64::MIN, f64::MIN); - let mut grow = |p: DVec3| { - lo = (lo.0.min(p.x), lo.1.min(p.z)); - hi = (hi.0.max(p.x), hi.1.max(p.z)); + // The area is the same every tick: worked out once for the recording. + let [l0, l1, h0, h1] = match self.area.get() { + Some(a) => a, + None => { + let a = area(rec, o); + self.area.set(Some(a)); + a + } }; - for tr in &rec.tracks { - if let What::Drone(k) = tr.what - && k.spec().ballistic.is_some() - { - continue; - } - for p in tr.path(o, 20, rec.len as f64) { - grow(p); - } - } - for sam in &rec.sams { - grow(sam.2 - DVec3::splat(60.0)); - grow(sam.2 + DVec3::splat(60.0)); - } - for p in &rec.objects { - grow(*p); - } - if lo.0 > hi.0 { - lo = (o.x - 200.0, o.z - 200.0); - hi = (o.x + 200.0, o.z + 200.0); - } + let (lo, hi) = ((l0, l1), (h0, h1)); let (top, bottom) = (20.0f32, 34.0f32); let (aw, ah) = (gw - 16.0, gh - top - bottom); let k = (aw as f64 / (hi.0 - lo.0).max(1.0)).min(ah as f64 / (hi.1 - lo.1).max(1.0)); @@ -638,7 +624,9 @@ impl Player { let (x, y) = at(*p); s.push(fill(x - 3.0, y - 3.0, 6.0, 6.0, 0xFFFFFFFF)); } - // Tracks up to now: a budget of segments shared out. + // Tracks up to now: a budget of segments shared out. The stride is + // worked out from the points there are, so only the segments drawn + // are ever copied. let n = rec.tracks.len().max(1); let per = (640 / n).clamp(2, 14); for tr in &rec.tracks { @@ -647,16 +635,21 @@ impl Player { What::Drone(_) => (0x90FF4040, 0xFFFF6060), What::Missile(..) => (0x90FFD040, 0xFFFFE070), }; - let pts = tr.path(o, 1, self.t); + let upto = ((self.t - tr.first as f64) / STEP as f64).floor(); + if upto < 0.0 { + continue; + } + let last = ((tr.last() - tr.first) / STEP) as f64; + let count = upto.min(last) as usize + 1; + if count < 2 { + continue; + } + let pts = tr.path(o, count.div_ceil(per).max(1), self.t); if pts.len() < 2 { continue; } - let every = pts.len().div_ceil(per).max(1); let mut prev = at(pts[0]); - for (i, p) in pts.iter().enumerate().skip(1) { - if i % every != 0 && i != pts.len() - 1 { - continue; - } + for p in pts.iter().skip(1) { let q = at(*p); if inside(prev) && inside(q) { s.push(line(prev, q, 1.0, color)); @@ -718,6 +711,41 @@ impl Player { } } +/// The ground the map shows, as [lo x, lo z, hi x, hi z]: everything that +/// flew (not the ballistic falls), with the systems and objects, or round the +/// origin when nothing did. +fn area(rec: &Recording, o: DVec3) -> [f64; 4] { + let mut lo = (f64::MAX, f64::MAX); + let mut hi = (f64::MIN, f64::MIN); + let mut grow = |p: DVec3| { + lo = (lo.0.min(p.x), lo.1.min(p.z)); + hi = (hi.0.max(p.x), hi.1.max(p.z)); + }; + for tr in &rec.tracks { + if let What::Drone(k) = tr.what + && k.spec().ballistic.is_some() + { + continue; + } + for p in tr.path(o, 20, rec.len as f64) { + grow(p); + } + } + // The systems, with some room round them. + for sam in &rec.sams { + grow(sam.2 - DVec3::splat(60.0)); + grow(sam.2 + DVec3::splat(60.0)); + } + for p in &rec.objects { + grow(*p); + } + if lo.0 > hi.0 { + lo = (o.x - 200.0, o.z - 200.0); + hi = (o.x + 200.0, o.z + 200.0); + } + [lo.0, lo.1, hi.0, hi.1] +} + impl Player { pub fn event_clip(rec: &Recording, i: usize) -> Mode { Mode::Clips { list: vec![clip(rec, i)], at: 0, reel: false } diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index 4307824..42f459d 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -159,8 +159,10 @@ impl Sim { return; } let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()].1; + // The drones sent at a new target: (waiting in the queue, index), to get a new route. + let mut retargeted: Vec<(bool, usize)> = Vec::new(); let mut n = 0usize; - for (_, d) in self.queue.iter_mut().filter(|(_, d)| d.smart) { + for (qi, (_, d)) in self.queue.iter_mut().enumerate().filter(|(_, (_, d))| d.smart) { let hunter = matches!(d.kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon); if !hunter && !(d.kind == DroneKind::Shahed && n % 4 == 0) { n += 1; @@ -173,18 +175,80 @@ impl Sim { let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) }; d.target = to; d.route.clear(); + retargeted.push((true, qi)); n += 1; } // In flight: a target system destroyed — the next known one, or keep going. - for d in self.drones.iter_mut().filter(|d| d.alive() && d.smart) { + for (di, d) in self.drones.iter_mut().enumerate().filter(|(_, d)| d.alive() && d.smart) { let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target) < 6.0); if aimed_at_wreck { let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos))); if let Some((_, at)) = next { d.target = *at; d.route.clear(); + retargeted.push((false, di)); } } } + self.replan_routes(&retargeted); + } + + /// Routes round the known defences for drones that were just sent at a new target, from where they are + /// (or wait). One grid and one set of prices cover them all, and the work is the planner's share for a + /// tick: those left over fly straight, as they did before there was a replan. A Lancet's strike on a + /// unit and the ballistic missiles go straight on purpose, as in the raid's own plan. + fn replan_routes(&mut self, jobs: &[(bool, usize)]) { + let drone = |s: &Sim, (queued, i): (bool, usize)| if queued { s.queue[i].1.clone() } else { s.drones[i].clone() }; + let jobs: Vec<(bool, usize)> = jobs + .iter() + .copied() + .filter(|&j| { + let d = drone(self, j); + let spec = d.kind.spec(); + d.smart && spec.ballistic.is_none() && !spec.decoy && d.kind != DroneKind::Lancet + }) + .collect(); + if jobs.is_empty() { + return; + } + // One grid over all their starts and goals, with room round them to go by. + let (mut lo, mut hi) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY)); + for &j in &jobs { + let d = drone(self, j); + for p in [d.pos, d.target] { + lo = lo.min(p); + hi = hi.max(p); + } + } + let span = (hi.x - lo.x).max(hi.z - lo.z) / 2.0 + 450.0; + let cell = raid::CELL.max(2.0 * span / raid::CELLS as f64); + let centre = (lo + hi) / 2.0; + let grid = raid::Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 }; + let mut prices: HashMap> = HashMap::new(); + let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec> = HashMap::new(); + let mut work = 0usize; + for j in jobs { + let d = drone(self, j); + if work >= raid::WORK_PER_TICK { + break; + } + let key = (d.kind, grid.index(d.pos), grid.index(d.target)); + let route = match routes.get(&key) { + Some(r) => r.clone(), + None => { + if !prices.contains_key(&d.kind) { + let (p, cost) = self.price_grid(&grid, d.kind); + work += cost; + prices.insert(d.kind, p); + } + let (r, cost) = raid::plan_route(&grid, &prices[&d.kind], d.pos, d.target); + work += cost; + routes.insert(key, r.clone()); + r + } + }; + let d = if j.0 { &mut self.queue[j.1].1 } else { &mut self.drones[j.1] }; + d.route = route; + } } } diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 1b862a5..ff50ab9 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -54,6 +54,8 @@ pub struct Particle { /// A blast the post-processing shader draws as a fireball, flash and shock. #[derive(Clone, Copy)] pub struct Blast { + /// Never reused while the simulation lives: the HD light keeps its identity through the blast's life. + pub id: u32, pub at: DVec3, /// Fireball radius in blocks. pub size: f32, @@ -163,6 +165,33 @@ fn slew(from: f64, to: f64, rate: f64) -> f64 { from + angle_diff(from, to).clamp(-rate, rate) } +/// One axis of a drive: speeds up towards `err` (the angle still to turn), brakes in time to stop on it, +/// and returns the turn this tick. Arriving takes the last bit at once, so there is no overshoot or jitter. +fn drive(vel: &mut f64, err: f64, speed: f64, accel: f64) -> f64 { + // The fastest it may go and still stop within what is left. + let fastest = (2.0 * accel * err.abs()).sqrt().min(speed); + let want = fastest.copysign(err); + *vel = (*vel + (want - *vel).clamp(-accel, accel)).clamp(-speed, speed); + if vel.abs() >= err.abs() && vel.signum() == err.signum() { + *vel = 0.0; + return err; + } + *vel +} + +/// Moves one system's claim from the drone it had to the one it has, in a count of claims per drone. +fn retarget(counts: &mut HashMap, from: Option, to: Option) { + if from == to { + return; + } + if let Some(n) = from.and_then(|id| counts.get_mut(&id)) { + *n = n.saturating_sub(1); + } + if let Some(id) = to { + *counts.entry(id).or_insert(0) += 1; + } +} + fn angle_diff(from: f64, to: f64) -> f64 { (to - from + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI } @@ -175,8 +204,13 @@ pub struct Ground { known: HashMap<(i32, i32), (i32, u64)>, wanted: Vec<(i32, i32)>, asked: std::collections::HashSet<(i32, i32)>, + /// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer). + swept: u64, } +/// Ticks between full clean-ups of a big ground cache. +const SWEEP_EVERY: u64 = 200; + impl Ground { /// One above the highest solid block, if known; the column is asked for /// when it isn't (or is old). @@ -203,8 +237,9 @@ impl Ground { for (x, z, y) in answers { self.known.insert((x, z), (y, now)); } - if self.known.len() > 300_000 { - self.known.retain(|_, (_, when)| now - *when < 2000); + if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY { + self.swept = now; + self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000); } } pub fn take_wanted(&mut self) -> Vec<(i32, i32)> { @@ -936,6 +971,9 @@ pub struct Sam { pub heading: f64, yaw: f64, pitch: f64, + /// How fast the mount turns now (radians a tick); not saved, a loaded system starts at rest. + yaw_vel: f64, + pitch_vel: f64, pub missiles: u32, pub bursts: u32, cooldown: u32, @@ -989,6 +1027,8 @@ impl Sam { heading, yaw: heading, pitch: spec.rest_pitch, + yaw_vel: 0.0, + pitch_vel: 0.0, missiles: spec.missile.as_ref().map_or(0, |m| m.magazine), bursts: spec.gun.as_ref().map_or(0, |g| g.bursts), cooldown: 40, @@ -1028,6 +1068,21 @@ impl Sam { self.health > 0.0 } /// How well it still works: turning, aiming, its radar's reach. + /// Turns the mount towards `yaw` (and `pitch`, if it elevates) under its drive's limits; a damaged + /// system has the less of both speed and acceleration. + fn aim_towards(&mut self, yaw: f64, pitch: Option) { + let d = self.kind.spec().drive(); + let fit = self.fitness(); + self.yaw += drive(&mut self.yaw_vel, angle_diff(self.yaw, yaw), d.yaw.0 * fit, d.yaw.1 * fit); + if let Some(pitch) = pitch { + self.pitch += drive(&mut self.pitch_vel, pitch - self.pitch, d.pitch.0 * fit, d.pitch.1 * fit); + } + } + /// The mount stops where it is (a march, a deployment: the vehicle drives it directly). + pub(super) fn halt_mount(&mut self) { + self.yaw_vel = 0.0; + self.pitch_vel = 0.0; + } fn fitness(&self) -> f64 { 0.45 + 0.55 * self.health.clamp(0.0, 1.0) } @@ -1094,6 +1149,8 @@ pub struct Sim { fx: Vec<(u32, Fx)>, /// Blasts for the post-processing shader. pub blasts: Vec, + /// The next blast's id (separate from `next`, so the other units' ids stay as they were). + blast_seq: u32, next: u32, pub rng: Rng, pub tick: u64, @@ -1144,6 +1201,7 @@ impl Sim { targets: Vec::new(), fx: Vec::new(), blasts: Vec::new(), + blast_seq: 0, next: 1, rng: Rng::new(seed), tick: 0, @@ -1312,8 +1370,8 @@ impl Sim { let now = self.tick; let mut boom: Vec<(DVec3, u32, bool)> = Vec::new(); let mut removed: Vec = Vec::new(); - // Oreshnik pieces released this tick: kind, at, velocity, target. - let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3)> = Vec::new(); + // Oreshnik pieces released this tick: kind, at, velocity, target, the parent's sector (what they teach the enemy). + let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3, u8)> = Vec::new(); // Warheads that reached their aim (for the replays): drone, kind, where, TNT; and splits. let mut impacts: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new(); let mut splits: Vec<(u32, DVec3)> = Vec::new(); @@ -1390,7 +1448,7 @@ impl Sim { let aim = d.target + DVec3::new(a.sin() * r, 0.0, a.cos() * r); // Straight down at the aim point, at the bus's speed. let v = (aim - d.pos).normalize_or(DVec3::NEG_Y) * 3.0; - released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim)); + released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim, d.sector)); } out.sound("airdef:boom.sonic", d.pos, 1.0, 0.7); splits.push((d.id, d.pos)); @@ -1522,7 +1580,7 @@ impl Sim { self.explode(pos, warhead, small, ground, player, out); } self.drones.retain(|d| !removed.contains(&d.id)); - for (kind, pos, vel, target) in released { + for (kind, pos, vel, target, sector) in released { let id = self.id(); self.drones.push(Drone { id, @@ -1538,7 +1596,7 @@ impl Sim { route: Vec::new(), low: false, jammed: false, - sector: 0, + sector, smart: false, }); } @@ -1637,7 +1695,8 @@ impl Sim { } fn fireball(&mut self, pos: DVec3, size: f64, life: u32) { - self.blasts.push(Blast { at: pos, size: size as f32, start: self.tick, life: life * 9 }); + self.blast_seq = self.blast_seq.wrapping_add(1); + self.blasts.push(Blast { id: self.blast_seq, at: pos, size: size as f32, start: self.tick, life: life * 9 }); if self.blasts.len() > 48 { self.blasts.remove(0); } @@ -1701,6 +1760,15 @@ impl Sim { return; } let logistics = self.logistics(); + // Drones by id (none come or go while the systems act), and how many systems have each as their + // missile target and as their gun target: counted once, then kept up to date as targets change. + let drone_at: HashMap = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect(); + let mut missile_on: HashMap = HashMap::new(); + let mut guns_on: HashMap = HashMap::new(); + for s in &self.sams { + retarget(&mut missile_on, None, s.target); + retarget(&mut guns_on, None, s.gun_target); + } let mut launches: Vec<(MissileKind, DVec3, DVec3, u32, u32)> = Vec::new(); let mut gun_hits: Vec<(u32, u32)> = Vec::new(); let mut tracers: Vec<(u32, DVec3, DVec3, u32)> = Vec::new(); @@ -1709,8 +1777,8 @@ impl Sim { if !self.sams[i].alive() { let sam = &mut self.sams[i]; sam.state = SamState::Destroyed; - sam.target = None; - sam.gun_target = None; + retarget(&mut missile_on, sam.target.take(), None); + retarget(&mut guns_on, sam.gun_target.take(), None); continue; } let fit = self.sams[i].fitness(); @@ -1738,56 +1806,65 @@ impl Sim { sam.state = SamState::Search; // A searchlight's target is its beam's (step_lights): kept for its spot of light. if sam.kind != SamKind::Light { - sam.target = None; + retarget(&mut missile_on, sam.target.take(), None); } - sam.gun_target = None; + retarget(&mut guns_on, sam.gun_target.take(), None); continue; } let pos = self.sams[i].pos; - let me = self.sams[i].id; let roles = self.sams[i].roles; - let seen = self.sams[i].seen.clone(); - let own = self.sams[i].own.clone(); - let allowed = |d: &Drone| d.alive() && roles & d.kind.spec().class.bit() != 0 && seen.contains(&d.id); - // Interceptors a target has or will have: in flight, and other systems' launchers on it. - let claimed = |d: &Drone| { - d.engaged + self.sams.iter().filter(|s| s.id != me && s.target == Some(d.id)).count() as u32 - }; - // Ballistic missiles get a pair; anything else one. - let needed = |d: &Drone| if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }; - if let Some(load) = &spec.missile { - let in_reach = |d: &Drone, k: f64| (load.min..load.max * k).contains(&d.pos.distance(pos)); - let keep = self.sams[i].target.filter(|t| { - self.drones.iter().any(|d| d.id == *t && allowed(d) && in_reach(d, 1.1)) + let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target); + let (target, gun_target) = { + let (seen, own) = (&self.sams[i].seen, &self.sams[i].own); + let allowed = |d: &Drone| d.alive() && roles & d.kind.spec().class.bit() != 0 && seen.contains(&d.id); + // Interceptors a target has or will have: in flight, and other systems' launchers on it. + let claimed = |d: &Drone| { + let on = missile_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_target == Some(d.id)); + d.engaged + on + }; + // Ballistic missiles get a pair; anything else one. + let needed = |d: &Drone| if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }; + let target = spec.missile.as_ref().map(|load| { + let in_reach = |d: &Drone, k: f64| (load.min..load.max * k).contains(&d.pos.distance(pos)); + let keep = had_target.filter(|t| { + drone_at.get(t).is_some_and(|&k| allowed(&self.drones[k]) && in_reach(&self.drones[k], 1.1)) + }); + keep.or_else(|| { + self.drones + .iter() + .filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d)) + .max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos))) + .map(|d| d.id) + }) }); - let target = keep.or_else(|| { + // Guns: the nearest, spread over the targets the other guns and missiles aren't on. + let gun_target = spec.gun.as_ref().map(|gun| { + let on_it = |d: &Drone| { + (guns_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_gun == Some(d.id))) as f64 + }; self.drones .iter() - .filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d)) - .max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos))) - .map(|d| d.id) + .filter(|d| allowed(d) && own.contains(&d.id) && d.pos.distance(pos) < gun.track) + .map(|d| { + let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 }; + (d.pos.distance(pos) * busy, d.id) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, id)| id) }); + (target, gun_target) + }; + if let Some(target) = target { + retarget(&mut missile_on, had_target, target); self.sams[i].target = target; } - // Guns: the nearest, spread over the targets the other guns and missiles aren't on. - if let Some(gun) = &spec.gun { - let guns_on = |d: &Drone| { - self.sams.iter().filter(|s| s.id != me && s.gun_target == Some(d.id)).count() as f64 - }; - self.sams[i].gun_target = self - .drones - .iter() - .filter(|d| allowed(d) && own.contains(&d.id) && d.pos.distance(pos) < gun.track) - .map(|d| { - let busy = 1.0 + 2.0 * guns_on(d) + if d.engaged > 0 { 1.5 } else { 0.0 }; - (d.pos.distance(pos) * busy, d.id) - }) - .min_by(|a, b| a.0.total_cmp(&b.0)) - .map(|(_, id)| id); + if let Some(gun_target) = gun_target { + retarget(&mut guns_on, had_gun, gun_target); + self.sams[i].gun_target = gun_target; } let sam = &mut self.sams[i]; // What the turret or launcher points at: guns first when a target is close. - let aim = sam.gun_target.or(sam.target).and_then(|t| self.drones.iter().find(|d| d.id == t)); + let aim = sam.gun_target.or(sam.target).and_then(|t| drone_at.get(&t).map(|&k| &self.drones[k])); sam.launched = sam.launched.saturating_sub(1); let Some(aim) = aim else { // Out of missiles or of shells, and nothing to shoot: reload now (both, as a @@ -1803,8 +1880,8 @@ impl Sim { } else { SamState::Search }; - sam.yaw = slew(sam.yaw, sam.heading, 0.03); - sam.pitch += (spec.rest_pitch - sam.pitch) * 0.05; + // Back to the rest position, under the drive's limits like any other turn. + sam.aim_towards(sam.heading, Some(spec.rest_pitch)); continue; }; let (mount, _) = sam.mount(); @@ -1813,21 +1890,21 @@ impl Sim { let to = lead - mount; let want_yaw = heading_of(to); let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45); - let (yaw_rate, pitch_rate) = spec.gun.as_ref().map_or((0.05, 0.05), |g| g.slew); - let (yaw_rate, pitch_rate) = (yaw_rate * fit, pitch_rate * fit); let cells = matches!(spec.muzzle, Muzzle::Cells); match spec.family { // Vertical launch: no aiming (the Tor's turret turns its radar to the target). - Family::Vertical if cells => sam.yaw = slew(sam.yaw, want_yaw, 0.15 * fit), + Family::Vertical if cells => sam.aim_towards(want_yaw, None), Family::Vertical => {} - Family::Trainable => sam.yaw = slew(sam.yaw, want_yaw, 0.12 * fit), - _ => { - sam.yaw = slew(sam.yaw, want_yaw, yaw_rate); - sam.pitch = slew(sam.pitch, want_pitch, pitch_rate); - } + // The launcher's elevation is fixed (the missile turns after its boost): only the training is aimed. + Family::Trainable => sam.aim_towards(want_yaw, None), + _ => sam.aim_towards(want_yaw, Some(want_pitch)), } - let aim_error = angle_diff(sam.yaw, want_yaw).abs() - + if spec.gun.is_some() { (want_pitch - sam.pitch).abs() } else { 0.0 }; + // How far the mount still is from the aim, over the axes this family aims. + let yaw_error = angle_diff(sam.yaw, want_yaw).abs(); + let aim_error = match spec.family { + Family::Trainable | Family::Vertical => yaw_error, + _ => yaw_error + (want_pitch - sam.pitch).abs(), + }; let vertical = spec.family == Family::Vertical; sam.state = if sam.reloading > 0 { SamState::Reload @@ -1846,7 +1923,7 @@ impl Sim { }; // Guns. if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) { - if let Some(t) = self.drones.iter().find(|d| d.id == gt) { + if let Some(t) = drone_at.get(>).map(|&k| &self.drones[k]) { let dist = t.pos.distance(mount); if aim.id == gt && aim_error < 0.1 && dist < gun.range && sam.gun_cooldown == 0 && sam.reloading == 0 && sam.bursts > 0 { sam.gun_cooldown = 2; @@ -1894,11 +1971,11 @@ impl Sim { let ready = sam.cooldown == 0 && sam.reloading == 0 && sam.missiles > 0; let aligned = match spec.family { Family::Trainable => { - let t = self.drones.iter().find(|d| d.id == mt); - // The missile turns after its boost: the launcher needn't be exact. - t.is_some_and(|t| angle_diff(sam.yaw, heading_of(t.pos - mount)).abs() < 0.45) + let t = drone_at.get(&mt).map(|&k| &self.drones[k]); + // The missile turns after its boost, but the launcher must point near the target. + t.is_some_and(|t| angle_diff(sam.yaw, heading_of(t.pos - mount)).abs() < 0.15) } - Family::Turret => aim.id == mt && aim_error < 0.2, + Family::Turret => aim.id == mt && aim_error < 0.15, _ => true, }; if ready && aligned { @@ -1907,10 +1984,11 @@ impl Sim { launches.push((load.kind, muzzle, dir, mt, sam.id)); // Its missile is on it from now, so the systems after this one this tick see // the target taken; this one's next launch looks for another. - if let Some(d) = self.drones.iter_mut().find(|d| d.id == mt) { - d.engaged += 1; + if let Some(&k) = drone_at.get(&mt) { + self.drones[k].engaged += 1; } sam.target = None; + retarget(&mut missile_on, Some(mt), None); sam.launched = 12; sam.missiles -= 1; sam.fired += 1; diff --git a/airdef-mod/src/sim/orders.rs b/airdef-mod/src/sim/orders.rs index 6d34968..98d51ac 100644 --- a/airdef-mod/src/sim/orders.rs +++ b/airdef-mod/src/sim/orders.rs @@ -56,6 +56,10 @@ impl Sim { continue; } let spec = sam.kind.spec(); + // The march and the packing drive the mount themselves: no speed carries over into or out of them. + if matches!(sam.state, SamState::Packing | SamState::Moving | SamState::Deploying) { + sam.halt_mount(); + } match sam.state { SamState::Packing => { // The launcher comes down for the march. diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index a086485..af8bdef 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -98,10 +98,10 @@ pub(super) struct WaveStats { } /// The planner's grid: cells this big, at most this many a side. -const CELL: f64 = 16.0; -const CELLS: i32 = 140; +pub(super) const CELL: f64 = 16.0; +pub(super) const CELLS: i32 = 140; /// The planner's work a tick (cells priced and opened): a raid takes a few ticks to plan, never a long one. -const WORK_PER_TICK: usize = 40_000; +pub(super) const WORK_PER_TICK: usize = 40_000; /// The planner's grid over a raid: its corner, its cells' size and how many a side. pub(super) struct Grid { @@ -111,7 +111,7 @@ pub(super) struct Grid { } impl Grid { - fn index(&self, p: DVec3) -> (i32, i32) { + pub(super) fn index(&self, p: DVec3) -> (i32, i32) { let n = self.size; ((((p.x - self.min.x) / self.cell) as i32).clamp(0, n - 1), (((p.z - self.min.z) / self.cell) as i32).clamp(0, n - 1)) } @@ -121,7 +121,7 @@ impl Grid { } /// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last. -fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec, usize) { +pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec, usize) { let mut opened = 0; let n = grid.size; let at = |(i, j): (i32, i32)| (i * n + j) as usize; @@ -426,8 +426,16 @@ impl Sim { let sp = s.kind.spec(); let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max)); let gun = sp.gun.as_ref().map_or(0.0, |g| g.range); - let radar = sight::reach(s, kind, height); - let far = hi.max(gun).min(radar); + // A gun needs its own radar; a missile also flies on the tracks of any radar within data-link range. + let own = sight::reach(s, kind, height); + let net = self + .sams + .iter() + .filter(|r| r.alive() && r.id != s.id && r.pos.distance(s.pos) <= sight::LINK) + .map(|r| sight::reach(r, kind, height)) + .fold(0.0, f64::max); + let (hi, gun) = (hi.min(own.max(net)), gun.min(own)); + let far = hi.max(gun); let at = sight::antenna(s); let horizon = self.horizons.get(&s.id); // Only the cells round it. diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index b1b8dd6..b5b27a8 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -98,3 +98,127 @@ fn munitions_must_go_down_to_the_land_left_under_a_destroyed_target() { assert!(blast.is_some_and(|b| b.y < 80.0), "{kind:?} burst at {blast:?}, in the air over the land"); } } + +// #15: lights of blasts in one tick at the same whole X must stay apart +#[test] +fn blasts_in_one_tick_have_their_own_ids() { + let mut sim = Sim::new(1); + sim.tick = 100; + sim.fireball(DVec3::new(0.0, 70.0, 100.0), 4.0, 10); + sim.fireball(DVec3::new(0.0, 70.0, 200.0), 4.0, 10); + assert_ne!(sim.blasts[0].id, sim.blasts[1].id); + // A blast going away does not hand its id to the next one. + let gone = sim.blasts.remove(0).id; + sim.fireball(DVec3::new(0.0, 70.0, 300.0), 4.0, 10); + assert!(sim.blasts.iter().all(|b| b.id != gone)); +} + +// #21: a smart drone sent at a new target gets a route again, not a straight line +#[test] +fn retargeted_smart_drone_gets_a_route_round_the_defences() { + let mut sim = Sim::new(1); + // A system in the way, between the drone and the unit it is sent at. + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0)); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0)); + let goal = sim.id(); + sim.sams.push(Sam::new(goal, SamKind::Ew, DVec3::new(0.0, 70.0, 1000.0), 0.0)); + sim.intel.revealed.push((goal, DVec3::new(0.0, 70.0, 1000.0), 0)); + // Its first target, a system that is destroyed by now. + let id = sim.id(); + let mut wreck = Sam::new(id, SamKind::Patriot, DVec3::new(3000.0, 70.0, -1000.0), 0.0); + wreck.health = 0.0; + sim.sams.push(wreck); + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, -1000.0), DVec3::Z, DVec3::new(3000.0, 70.0, -1000.0)); + sim.drones[0].pos = DVec3::new(0.0, 100.0, -1000.0); + sim.drones[0].smart = true; + sim.tick = 40; + sim.adapt(); + let d = &sim.drones[0]; + assert!(d.target.distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit"); + assert!(d.route.len() > 1, "no route after the new target: it flies straight through the defences"); +} + +// #31: a big cache of fresh columns is not scanned on every answer +#[test] +fn big_ground_cache_is_swept_rarely() { + let mut ground = Ground::default(); + ground.known.extend((0..300_001).map(|x| ((x, 0), (64, 2000)))); + ground.known.insert((-1, 0), (64, 0)); + // The first answer past the threshold sweeps what is stale and keeps what is fresh. + ground.answer(vec![(-2, 0, 64)], 2100); + assert!(!ground.known.contains_key(&(-1, 0)), "a stale column was never swept"); + assert!(ground.known.len() > 300_000); + // The next ones wait their pause: a stale column added now survives them. + ground.known.insert((-1, 0), (64, 0)); + let swept = ground.swept; + ground.answer(vec![(-3, 0, 64)], 2101); + assert_eq!(ground.swept, swept, "swept again at once"); + assert!(ground.known.contains_key(&(-1, 0))); + ground.answer(vec![(-4, 0, 64)], 2100 + SWEEP_EVERY); + assert!(!ground.known.contains_key(&(-1, 0)), "never swept again"); +} + +// #32: the pieces of an Oreshnik teach the enemy about the sector it came from +#[test] +fn oreshnik_pieces_keep_the_sector() { + let mut sim = Sim::new(1); + sim.add_drone(DroneKind::Oreshnik, DVec3::new(100.0, 300.0, 0.0), DVec3::NEG_Y, DVec3::new(0.0, 70.0, 0.0)); + sim.drones[0].vel = DVec3::new(-1.0, -1.0, 0.0); + sim.drones[0].sector = 7; + let mut out = Out::default(); + sim.step_drones(&mut Ground::default(), None, &mut out); + let pieces: Vec<_> = sim.drones.iter().filter(|d| d.kind == DroneKind::OreshnikBlock).collect(); + assert_eq!(pieces.len(), 6); + assert!(pieces.iter().all(|d| d.sector == 7)); +} + +// #33: a turret does not fire before it has turned to the target +#[test] +fn turret_waits_for_its_aim() { + let mut sim = Sim::new(1); + let id = sim.id(); + let mut s = Sam::new(id, SamKind::Gepard, DVec3::new(0.0, 70.0, 0.0), 0.0); + s.cooldown = 0; + sim.sams.push(s); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0)); + // Straight behind the turret's heading, a slow target well inside the gun's reach. + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 60.0), DVec3::ZERO, DVec3::new(0.0, 70.0, 900.0)); + sim.drones[0].vel = DVec3::ZERO; + let (mut ground, mut out) = (Ground::default(), Out::default()); + let mut turned = 0; + for _ in 0..200 { + sim.step(&mut ground, None, &mut out); + let g = &sim.sams[0]; + let want = heading_of(sim.drones[0].pos - g.mount().0); + if angle_diff(g.yaw, want).abs() > 0.1 { + assert_eq!(g.bursts, 160, "fired with the turret still turning"); + turned += 1; + } + if g.bursts < 160 || sim.drones.is_empty() { + break; + } + } + assert!(turned > 20, "the turret turned too fast to tell ({turned} ticks)"); + assert!(sim.sams[0].bursts < 160, "never fired"); +} + +// #33: the drive speeds up, brakes in time and does not overshoot +#[test] +fn drive_arrives_without_overshoot() { + for err in [std::f64::consts::PI, 1.57, 0.3, 0.02] { + let (mut vel, mut left) = (0.0, err); + let (speed, accel) = (0.088, 0.011); + let mut ticks = 0; + while left > 0.0 { + let step = drive(&mut vel, left, speed, accel); + assert!(step.abs() <= speed + 1e-9 && step >= 0.0, "step {step} at {left} left"); + left -= step; + ticks += 1; + assert!(ticks < 400, "never arrived for {err}"); + } + assert!(left.abs() < 1e-9 && vel == 0.0); + } +} diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index f43626b..1066e4d 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -90,7 +90,8 @@ impl Sim { self.horizons.insert(sam.id, h); } } - self.horizons.retain(|id, _| self.sams.iter().any(|s| s.id == *id && s.alive())); + let alive: std::collections::HashSet = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect(); + self.horizons.retain(|id, _| alive.contains(id)); // Each drone's height over the land under it (or over the systems' ground, unknown). let heights: Vec> = self.drones.iter().map(|d| ground.at(d.pos, now).map(|g| d.pos.y - g)).collect(); @@ -120,18 +121,20 @@ impl Sim { // The network: every unit gets the tracks of the radars within data-link range. let tracks: Vec<(DVec3, Vec)> = self.sams.iter().filter(|s| !s.own.is_empty()).map(|s| (s.pos, s.own.clone())).collect(); + // Merged in the radars' order, each track once: a set marks what is in already. + let mut have: std::collections::HashSet = std::collections::HashSet::new(); for sam in &mut self.sams { + sam.seen.clone_from(&sam.own); if !sam.alive() || sam.moving() || !sam.kind.armed() { - sam.seen = sam.own.clone(); continue; } - let mut seen = sam.own.clone(); + have.clear(); + have.extend(sam.own.iter().copied()); for (at, ids) in &tracks { if at.distance(sam.pos) <= LINK { - seen.extend(ids.iter().copied().filter(|id| !seen.contains(id)).collect::>()); + sam.seen.extend(ids.iter().copied().filter(|id| have.insert(*id))); } } - sam.seen = seen; } } diff --git a/airdef-mod/src/sim/units.rs b/airdef-mod/src/sim/units.rs index 39cbd2d..810816d 100644 --- a/airdef-mod/src/sim/units.rs +++ b/airdef-mod/src/sim/units.rs @@ -99,6 +99,19 @@ pub(super) struct GunSpec { pub slew: (f64, f64), } +/// How fast a weapon's mount turns: per axis the top speed (radians a tick) and the acceleration, which +/// is also the braking (radians a tick²). Game settings, not the real machines' figures. +#[derive(Clone, Copy)] +pub(super) struct Drive { + pub yaw: (f64, f64), + pub pitch: (f64, f64), +} + +/// The drives are this share of the speeds they had before they had a speed of their own. +const DRIVE_SCALE: f64 = 0.4; +/// Ticks a drive takes to reach its top speed (and to stop from it). +const DRIVE_RAMP: f64 = 8.0; + pub struct SamSpec { pub name: &'static str, pub key: &'static str, @@ -122,6 +135,20 @@ pub struct SamSpec { pub mast: f64, } +impl SamSpec { + /// The mount's drive: the guns' own slew rates, the old rates of the launchers, scaled down. + pub(super) fn drive(&self) -> Drive { + let (yaw, pitch) = match (&self.gun, self.family) { + (Some(g), _) => g.slew, + (None, Family::Trainable) => (0.12, 0.0), + (None, Family::Vertical) => (0.15, 0.0), + (None, _) => (0.05, 0.05), + }; + let axis = |speed: f64| (speed * DRIVE_SCALE, speed * DRIVE_SCALE / DRIVE_RAMP); + Drive { yaw: axis(yaw), pitch: axis(pitch) } + } +} + const NO_PARTS: Parts = Parts { body: "", launcher: None, pack: None, dish: None, fixed: &[], spinner: None }; /// The battery radar trailer behind a vertical launcher. const BATTERY_RADAR: &[(&str, f64, f64)] = &[("airdef:radar_trailer", 0.0, 4.2)]; diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 958e424..e021e14 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -55,7 +55,7 @@ cd ~/Downloads/airdef-macos-arm64 ```powershell rustup target add wasm32-unknown-unknown -git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git +git clone https://gitea.maoniao.team/chexit/airdef.git cd airdef py tools\airdef.py all # форк Horizon + сборка игры + мод + установка мода py tools\airdef.py play # запуск; мир: py tools\airdef.py play --world "Имя мира" @@ -69,12 +69,14 @@ py tools\airdef.py play # запуск; мир: py tools\airdef.py play ```sh rustup target add wasm32-unknown-unknown -git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git +git clone https://gitea.maoniao.team/chexit/airdef.git cd airdef python3 tools/airdef.py all python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "Имя мира" ``` +Клон по HTTPS не требует ключа. Если у тебя есть SSH-ключ в Gitea, можно и так: `git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git`. + Шаги по отдельности: `fork` (скачать Horizon и наложить патчи `horizon-patches/`), `game` (собрать игру), `mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`.