P2/P3 issues: network-fed launchers priced into smart routes (#4), stable blast light ids (#15), retargeted smart drones replanned round the defences (#21), sim tick without per-unit clones and linear scans (#26), ground cache swept rarely (#31), Oreshnik pieces keep their sector (#32), drives with acceleration and fire only once aimed (#33), radar monitor range (#19), delayed sound volume (#22), world change clears transient state (#23), score line always shown (#24), replay map without full paths (#25), text fit by bisection (#27), docs (#29), glow occlusion by ray distance (#14), blasts composited nearest first (#16)
build / mod (push) Successful in 2m26s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 16:28:24 +03:00
parent a806635681
commit 67a2a523b4
14 changed files with 534 additions and 141 deletions
+5 -4
View File
@@ -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 самых интересных событий (перехват баллистики и гиперзвука, поражения
объектов, потерянные комплексы, разделение «Орешника») с титрами и замедлением; колесо листает моменты.
+5 -2
View File
@@ -197,7 +197,10 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
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<f32>, pixel: vec2<f32>) -> vec4<f32> {
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<f32>(a.xyz * 0.7 + vec3<f32>(t_now * 1.3)));
+31 -8
View File
@@ -85,8 +85,17 @@ struct AirDef {
menu: Option<Page>,
/// 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<PendingSound>,
}
/// 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();
+24 -7
View File
@@ -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<usize> = 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<Sha
s.push(quad(p(a0, r - 0.4), p(a0, r + 0.4), p(a1, r + 0.4), p(a1, r - 0.4), 0x9040FF60));
}
}
let reach = sam.kind.range();
// A system that only fires (no radar) has no reach: a radar's sensor
// range stands in, and nothing is divided by zero.
let reach = match sam.kind.range() {
r if r > 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<Sha
for d in sim.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)) {
let rel = d.pos - sam.pos;
let flat = (rel.x * rel.x + rel.z * rel.z).sqrt();
if flat > 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) {
+15 -4
View File
@@ -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<Line> {
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<Line> {
}
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
}
+63 -35
View File
@@ -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<Option<[f64; 4]>>,
}
/// 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<Shape> {
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 }
+66 -2
View File
@@ -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<DroneKind, Vec<f32>> = HashMap::new();
let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec<DVec3>> = 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;
}
}
}
+143 -65
View File
@@ -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<u32, u32>, from: Option<u32>, to: Option<u32>) {
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<f64>) {
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<Blast>,
/// 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<u32> = 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<u32, usize> = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect();
let mut missile_on: HashMap<u32, u32> = HashMap::new();
let mut guns_on: HashMap<u32, u32> = 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(&gt).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;
+4
View File
@@ -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.
+15 -7
View File
@@ -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<DVec3>, usize) {
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, 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.
+124
View File
@@ -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);
}
}
+8 -5
View File
@@ -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<u32> = 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<Option<f64>> =
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<u32>)> =
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<u32> = 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::<Vec<_>>());
sam.seen.extend(ids.iter().copied().filter(|id| have.insert(*id)));
}
}
sam.seen = seen;
}
}
+27
View File
@@ -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)];
+4 -2
View File
@@ -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`.