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
build / mod (push) Successful in 2m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 самых интересных событий (перехват баллистики и гиперзвука, поражения
|
||||
объектов, потерянные комплексы, разделение «Орешника») с титрами и замедлением; колесо листает моменты.
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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(>).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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user