From 38e515dea90158879d28b8d1de963f66c7d5690d Mon Sep 17 00:00:00 2001 From: chexit Date: Sat, 31 Oct 2026 02:19:31 +0300 Subject: [PATCH] Flights over unknown land hold a safe height, air starts wait for the land, systems and aims settle on the surface; map labels, filters, coverage by terrain and scenes Far raids over mountains with a 300-block known disc: crashes from 19 to 2 of 368 (mixed), 22 to about 9 (smart). Balance tests gain hilly land. Co-Authored-By: Claude Opus 5.5 --- airdef-mod/src/lib.rs | 2 + airdef-mod/src/map.rs | 1209 ++++++++++++++++++++++++++++----- airdef-mod/src/sim/balance.rs | 241 ++++++- airdef-mod/src/sim/mod.rs | 85 ++- airdef-mod/src/sim/raid.rs | 47 +- airdef-mod/src/sim/regress.rs | 75 ++ airdef-mod/src/sim/sight.rs | 162 +++++ airdef-mod/src/sim/sites.rs | 19 + airdef-mod/src/sim/terrain.rs | 21 +- 9 files changed, 1650 insertions(+), 211 deletions(-) diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 59349dc..4a98984 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -2110,6 +2110,8 @@ impl Mod for AirDef { sam.roles = *r; } self.sim.sams.push(sam); + // Standing in the ground (a chunk that had not loaded as the player fell): it comes up to the surface. + self.sim.settle(sid); self.dirty = true; Some(format!("{} развёрнут", kind.name())) } diff --git a/airdef-mod/src/map.rs b/airdef-mod/src/map.rs index be47c1c..bef71b6 100644 --- a/airdef-mod/src/map.rs +++ b/airdef-mod/src/map.rs @@ -7,14 +7,14 @@ //! comes from the game as tiles (`render::TerrainShape`): the mod only says which, and the game scans the //! loaded chunks within a budget; land it hasn't got is drawn as unknown. use crate::panel::distance; -use crate::sim::{self, Class, Sam, SamKind, Sim, doctrine::Doctrine, Track, strike::SiteState}; +use crate::sim::{self, Class, LowSensor, Sam, SamKind, Sim, doctrine::Doctrine, Track, strike::SiteState}; use crate::{AirDef, say}; use glam::DVec3; use horizon_mod_sdk::render::{self, CanvasEvent, FillShape, Point, QuadShape, Rect, Shape, TerrainShape, TextShape}; use std::collections::HashMap; use std::f32::consts::TAU; -const FILTERS: [&str; 7] = ["рельеф", "системы", "объекты", "цели сети", "следы", "пусковые", "дальности"]; +const FILTERS: [&str; 9] = ["рельеф", "системы", "объекты", "цели сети", "следы", "пусковые", "дальности", "обзор РЛС", "заливка"]; const F_TERRAIN: usize = 0; const F_SAMS: usize = 1; const F_OBJECTS: usize = 2; @@ -22,6 +22,29 @@ const F_TRACKS: usize = 3; const F_TRAILS: usize = 4; const F_SITES: usize = 5; const F_RINGS: usize = 6; +const F_VIEW: usize = 7; +const F_TINT: usize = 8; + +/// Classes of systems for the filter: missile systems, guns, radars, the rear. +const SYS_CLASSES: [&str; 4] = ["ЗРК", "ЗАК", "РЛС", "тыл"]; +/// Classes of the network's targets, by the speed of the track (the network doesn't know what a target is). +const TGT_CLASSES: [&str; 5] = ["Д", "Г", "К", "С", "Б"]; +/// Speed (blocks a tick) below which a track is of the class: drones, jets, cruise, supersonic, the rest ballistic. +const TGT_SPEED: [f64; 4] = [0.8, 1.8, 3.0, 6.5]; + +/// A label line's height, pixels the markers merge within, how far from the pointer labels are kept at +/// a low zoom, and the most labels at once. +const LH: f32 = 9.0; +const CLUSTER_R: f32 = 9.0; +const MAX_LABELS: usize = 70; +/// Ticks a radar's computed coverage edge lives, and how many are recomputed in a redraw. +const RIM_REFRESH: u64 = 100; +const RIMS_PER_FRAME: usize = 3; +/// The most dashes of coverage edges in a redraw, the most cells of the tint, and the most land questions for one tint. +const RIM_DASHES: usize = 2400; +const TINT_CELLS: f32 = 1000.0; +const TINT_ASKS: usize = 12_000; +const SCN_ROWS: usize = 6; /// Blocks per pixel limits: the whole raid, and one vehicle. const SCALE_MIN: f64 = 0.04; @@ -41,8 +64,6 @@ const RED: u32 = 0xFFFF5050; const CYAN: u32 = 0xFF9FE0FF; const ORANGE: u32 = 0xFFFFA040; const PANEL: u32 = 0xD0101814; -/// The right card's width in GUI pixels. -const CARD_W: f32 = 196.0; #[derive(Clone, Copy, PartialEq, Debug)] enum Sel { @@ -91,6 +112,14 @@ enum Act { Delete, Close, Player, + SysClass(usize), + TgtClass(usize), + Tab(usize), + Panel, + NameFocus, + SaveScn, + LoadScn(usize), + ScnPage(i32), } /// A system in the draft layout: an existing one (by id) or one to be built. @@ -127,6 +156,37 @@ struct Btn { act: Act, } +/// A coverage edge worked out: how far a low flyer is seen along each ray before the land hides it. +struct Rim { + tick: u64, + pos: DVec3, + reach: f64, + terrain: bool, + d: Vec, +} + +/// The coverage tint of a view, kept while the view and the layout stay the same: runs of cells (x, y, w, h, colour). +#[derive(Default)] +struct Tint { + key: u64, + runs: Vec<([f32; 4], u32)>, +} + +/// Markers that merged into one at this zoom: where, and what a click on them zooms to. +struct Cluster { + c: (f32, f32), +} + +/// A label waiting for a place. +struct Lab { + text: String, + w: f32, + at: (f32, f32), + r: f32, + col: u32, + prio: f32, +} + /// What the map keeps between frames. pub struct MapUi { pub open: bool, @@ -139,7 +199,27 @@ pub struct MapUi { sel: Option, follow: bool, mode: Mode, - filters: [bool; 7], + filters: [bool; 9], + sclass: [bool; 4], + tclass: [bool; 5], + /// The left panel's page (layers, filters, scenarios) and whether it is folded away. + tab: usize, + folded: bool, + /// The layout's scale for the window (1 small, up to 1.4 large). + u: f32, + /// Scenario buttons: the name being typed, whether the field takes the keys, the names saved, the page of them, + /// and what a second click confirms (a delete, an overwrite, a load from another world). + name: String, + name_focus: bool, + scn: Vec, + scn_page: usize, + confirm: Option<(Act, String)>, + rims: HashMap, + tint: Tint, + clusters: Vec, + /// Where panels are, so labels keep clear of them. + blocked: Vec<[f32; 4]>, + widths: HashMap, trails: HashMap>, seen: HashMap, sampled: u64, @@ -175,7 +255,22 @@ impl Default for MapUi { sel: None, follow: false, mode: Mode::View, - filters: [true; 7], + filters: [true, true, true, true, true, true, true, true, false], + sclass: [true; 4], + tclass: [true; 5], + tab: 0, + folded: false, + u: 1.0, + name: String::new(), + name_focus: false, + scn: Vec::new(), + scn_page: 0, + confirm: None, + rims: HashMap::new(), + tint: Tint::default(), + clusters: Vec::new(), + blocked: Vec::new(), + widths: HashMap::new(), trails: HashMap::new(), seen: HashMap::new(), sampled: 0, @@ -299,6 +394,165 @@ impl MapUi { } } +impl MapUi { + /// A line's width in GUI pixels; the game is asked once for each text. + fn tw(&mut self, t: &str) -> f32 { + if let Some(w) = self.widths.get(t) { + return *w; + } + if self.widths.len() > 1500 { + self.widths.clear(); + } + let w = render::text_width(t); + self.widths.insert(t.to_string(), w); + w + } + + /// The names of the saved scenarios, and a name for the next save that no scenario has. + fn refresh_scenarios(&mut self) { + self.scn = crate::scenario::saved_names(); + self.scn_page = self.scn_page.min(self.scn.len().saturating_sub(1) / SCN_ROWS); + if self.name.is_empty() || self.scn.contains(&self.name) && !self.name_focus { + self.name = (1..).map(|n| format!("map-{n}")).find(|n| !self.scn.contains(n)).unwrap_or_default(); + } + } +} + +/// The class of a system in the filter: 0 missile systems, 1 guns, 2 radars, 3 the rear. +fn sys_class(k: SamKind) -> usize { + if k.armed() { + if k == SamKind::Gepard { 1 } else { 0 } + } else if k.spec().sensor > 0.0 { + 2 + } else { + 3 + } +} + +/// A name that fits beside a marker. +fn short_name(k: SamKind) -> &'static str { + match k { + SamKind::Radar => "РЛС", + SamKind::Depot => "Склад", + SamKind::Tzm => "ТЗМ", + SamKind::Ew => "РЭБ", + SamKind::Light => "Прож.", + SamKind::Pantsir => "Панцирь", + SamKind::IrisT => "IRIS-T", + SamKind::S400 => "С-400", + SamKind::S500 => "С-500", + SamKind::Buk => "Бук", + SamKind::Tor => "Тор", + SamKind::Gepard => "Гепард", + _ => k.name(), + } +} + +/// The class of a track by its speed. +fn tgt_class(t: &Track) -> usize { + let v = t.vel.length(); + TGT_SPEED.iter().position(|&m| v < m).unwrap_or(TGT_SPEED.len()) +} + +/// Markers within `r` pixels of the first of a group go with it: the groups, as indices into `pos`. +fn group(pos: &[(f32, f32)], r: f32) -> Vec> { + let r2 = r * r; + let mut g: Vec> = Vec::new(); + 'next: for (i, p) in pos.iter().enumerate() { + for grp in g.iter_mut() { + let a = pos[grp[0]]; + if (a.0 - p.0).powi(2) + (a.1 - p.1).powi(2) <= r2 { + grp.push(i); + continue 'next; + } + } + g.push(vec![i]); + } + g +} + +/// The groups of markers: those in `solo` stand alone; the rest merge when `cluster` is on. +fn groups_of(pos: &[(f32, f32)], solo: &[bool], r: f32, cluster: bool) -> Vec> { + let mut g: Vec> = (0..pos.len()).filter(|&i| solo[i]).map(|i| vec![i]).collect(); + let rest: Vec = (0..pos.len()).filter(|&i| !solo[i]).collect(); + if !cluster { + g.extend(rest.into_iter().map(|i| vec![i])); + return g; + } + let ps: Vec<(f32, f32)> = rest.iter().map(|&i| pos[i]).collect(); + g.extend(group(&ps, r).into_iter().map(|grp| grp.into_iter().map(|k| rest[k]).collect::>())); + g +} + +/// Several markers as one: a box with their number, in the middle of them; a click zooms in on it. +fn draw_cluster(ui: &mut MapUi, s: &mut Vec, taken: &mut Vec<[f32; 4]>, members: &[(f32, f32)], col: u32) { + let n = members.len() as f32; + let c = (members.iter().map(|m| m.0).sum::() / n, members.iter().map(|m| m.1).sum::() / n); + let label = members.len().to_string(); + let half = (ui.tw(&label) / 2.0 + 3.0).max(7.0); + s.push(fill(c.0 - half - 1.0, c.1 - 6.0, half * 2.0 + 2.0, 12.0, col)); + s.push(fill(c.0 - half, c.1 - 5.0, half * 2.0, 10.0, 0xFF101810)); + s.push(text_c(label, c.0, c.1 - 4.0, col)); + taken.push([c.0 - half - 1.0, c.1 - 6.0, half * 2.0 + 2.0, 12.0]); + ui.clusters.push(Cluster { c }); +} + +/// Asks for a label beside a marker; near the pointer it is kept, far from it at a low zoom it isn't. +fn add_lab(ui: &mut MapUi, labs: &mut Vec, text: String, at: (f32, f32), r: f32, col: u32, chosen: bool) { + let d = (at.0 - ui.cursor.0).hypot(at.1 - ui.cursor.1); + let attn = if ui.scale < 0.15 { + 90.0 + } else if ui.scale < 0.6 { + 200.0 + } else { + f32::MAX + }; + if !chosen && d > attn { + return; + } + let w = ui.tw(&text); + labs.push(Lab { text, w, at, r, col, prio: if chosen { -1.0e6 } else { d } }); +} + +fn overlap(a: [f32; 4], b: [f32; 4]) -> bool { + a[0] < b[0] + b[2] && a[0] + a[2] > b[0] && a[1] < b[1] + b[3] && a[1] + a[3] > b[1] +} + +/// Puts the labels where nothing else is: the selected first, then those nearest the pointer; each beside its +/// marker (right, left, above, below), or not at all. `taken` holds the panels and the markers. +fn place_labels(size: (f32, f32), mut labs: Vec, mut taken: Vec<[f32; 4]>, out: &mut Vec) { + labs.sort_by(|a, b| a.prio.total_cmp(&b.prio)); + let mut put = 0; + for l in labs { + if put >= MAX_LABELS { + break; + } + let (x, y) = l.at; + let gap = l.r + 2.0; + let tries = [ + (x + gap, y - LH / 2.0), + (x - gap - l.w, y - LH / 2.0), + (x - l.w / 2.0, y - gap - LH), + (x - l.w / 2.0, y + gap), + (x + gap, y + 1.0), + (x + gap, y - LH - 1.0), + ]; + for (tx, ty) in tries { + let r = [tx, ty, l.w, LH]; + if tx < 0.0 || ty < 0.0 || tx + l.w > size.0 || ty + LH > size.1 { + continue; + } + if taken.iter().any(|t| overlap(r, [t[0] - 1.0, t[1] - 1.0, t[2] + 2.0, t[3] + 2.0])) { + continue; + } + out.push(text(l.text.as_str(), tx, ty, l.col)); + taken.push(r); + put += 1; + break; + } + } +} + /// Whether a system reaches the point (its weapons, on the plane). fn reaches(kind: SamKind, pos: DVec3, p: DVec3) -> bool { kind.range() > 0.0 && (pos.x - p.x).hypot(pos.z - p.z) <= kind.range() @@ -332,7 +586,9 @@ impl AirDef { if !ui.placed { ui.centre = (p.x, p.z); ui.placed = true; + ui.folded = ui.size.0 < 520.0; } + ui.refresh_scenarios(); ui.dirty = true; ui.drawn = u64::MAX; render::open_canvas(); @@ -459,6 +715,8 @@ impl AirDef { self.map_act(ui, act); return; } + ui.name_focus = false; + ui.confirm = None; } match (b.button, ui.mode) { (0, Mode::Editor) => self.editor_press(ui, at), @@ -487,6 +745,27 @@ impl AirDef { fn map_key(&mut self, ui: &mut MapUi, key: &str) { ui.dirty = true; + // The name of a scenario being typed takes every key. + if ui.name_focus { + match key { + "key.keyboard.escape" => ui.name_focus = false, + "key.keyboard.enter" | "key.keyboard.keypad.enter" => self.map_act(ui, Act::SaveScn), + "key.keyboard.backspace" | "key.keyboard.delete" => { + ui.name.pop(); + } + "key.keyboard.minus" if ui.name.len() < 32 => ui.name.push('-'), + k => { + if let Some(c) = k.strip_prefix("key.keyboard.") + && c.len() == 1 + && c.as_bytes()[0].is_ascii_alphanumeric() + && ui.name.len() < 32 + { + ui.name.push(c.as_bytes()[0].to_ascii_lowercase() as char); + } + } + } + return; + } let step = (60.0 / ui.scale).max(2.0); match key { "key.keyboard.escape" => { @@ -536,6 +815,15 @@ impl AirDef { ui.filters[n] = !ui.filters[n]; } } + "key.keyboard.s" => { + ui.folded = false; + ui.tab = 2; + ui.refresh_scenarios(); + } + "key.keyboard.n" if ui.tab == 2 && !ui.folded => ui.name_focus = true, + "key.keyboard.tab" => { + ui.folded = !ui.folded; + } _ => {} } } @@ -572,7 +860,73 @@ impl AirDef { fn map_act(&mut self, ui: &mut MapUi, act: Act) { ui.dirty = true; let tick = self.sim.tick; + if act != Act::NameFocus { + ui.name_focus = false; + } + if !matches!(act, Act::SaveScn | Act::LoadScn(_)) { + ui.confirm = None; + } match act { + Act::SysClass(i) => ui.sclass[i] = !ui.sclass[i], + Act::TgtClass(i) => ui.tclass[i] = !ui.tclass[i], + Act::Tab(i) => { + ui.tab = i; + if i == 2 { + ui.refresh_scenarios(); + } + } + Act::Panel => ui.folded = !ui.folded, + Act::NameFocus => ui.name_focus = true, + Act::ScnPage(d) => { + let pages = ui.scn.len().div_ceil(SCN_ROWS).max(1); + ui.scn_page = (ui.scn_page as i32 + d).clamp(0, pages as i32 - 1) as usize; + } + Act::SaveScn => { + let name = ui.name.clone(); + if ui.scn.contains(&name) && !matches!(&ui.confirm, Some((Act::SaveScn, n)) if *n == name) { + ui.confirm = Some((Act::SaveScn, name)); + ui.note(tick, "Такое имя уже есть: нажмите ещё раз, чтобы перезаписать"); + return; + } + ui.confirm = None; + match self.scenario_command(&["save", &name]) { + Ok(m) => { + say(&m); + ui.note(tick, m); + } + Err(e) => ui.note(tick, e), + } + ui.refresh_scenarios(); + } + Act::LoadScn(i) => { + let Some(name) = ui.scn.get(i).cloned() else { return }; + let again = matches!(&ui.confirm, Some((Act::LoadScn(k), _)) if *k == i); + let args: Vec<&str> = if again { vec!["load", &name, "force"] } else { vec!["load", &name] }; + match self.scenario_command(&args) { + Ok(m) => { + say(&m); + ui.confirm = None; + ui.sel = None; + ui.follow = false; + ui.draft = Draft::default(); + ui.history.clear(); + ui.mode = Mode::View; + ui.pick = Pick::None; + ui.ed_sel = None; + ui.trails.clear(); + ui.rims.clear(); + ui.note(tick, m); + } + Err(e) if e.contains("другом мире") && !again => { + ui.confirm = Some((Act::LoadScn(i), name)); + ui.note(tick, "Сценарий сделан в другом мире: нажмите ещё раз, чтобы загрузить всё равно"); + } + Err(e) => { + ui.confirm = None; + ui.note(tick, e); + } + } + } Act::Filter(i) => ui.filters[i] = !ui.filters[i], Act::Close => { ui.close = true; @@ -729,7 +1083,7 @@ impl AirDef { (s.0 - p.0).hypot(s.1 - p.1) <= PICK_R }; if ui.filters[F_SAMS] { - if let Some(s) = self.sim.sams.iter().find(|s| near(s.pos)) { + if let Some(s) = self.sim.sams.iter().find(|s| near(s.pos) && ui.sclass[sys_class(s.kind)]) { return Some(Sel::Sam(s.id)); } } @@ -752,6 +1106,9 @@ impl AirDef { let pic = self.sim.picture(); let mut best: Option<(f32, u32)> = None; for (id, t) in &pic { + if !ui.tclass[tgt_class(t)] { + continue; + } let e = t.est(now); let s = ui.to_screen(e.x, e.z); let d = (s.0 - p.0).hypot(s.1 - p.1); @@ -767,6 +1124,14 @@ impl AirDef { } fn map_click(&mut self, ui: &mut MapUi, at: (f32, f32)) { + // Markers merged into one: a click goes in on them. + if let Some(cl) = ui.clusters.iter().find(|c| (c.c.0 - at.0).hypot(c.c.1 - at.1) <= 11.0) { + let (x, z) = ui.to_world(cl.c); + ui.centre = (x, z); + ui.scale = (ui.scale * 3.0).clamp(SCALE_MIN, SCALE_MAX); + ui.follow = false; + return; + } let hit = self.pick_at(ui, at); match ui.mode { Mode::Guard(id) => { @@ -966,9 +1331,11 @@ impl AirDef { // ------------------------------------------------------------------ drawing fn map_shapes(&mut self, ui: &mut MapUi) -> Vec { - let mut s: Vec = Vec::with_capacity(2048); ui.buttons.clear(); + ui.clusters.clear(); + ui.blocked.clear(); let (w, h) = ui.size; + ui.u = (h / 300.0).clamp(1.0, 1.4); let now = self.sim.tick; let editing = ui.mode == Mode::Editor; let cur_world = ui.to_world(ui.cursor); @@ -984,35 +1351,28 @@ impl AirDef { render::load_chunks(&wanted); } let draft = ui.draft.clone(); + // The panels first: the labels keep clear of them. They are drawn last, over the picture. + let mut panels: Vec = Vec::with_capacity(400); + self.map_panels(ui, &mut panels, &draft); + let mut s: Vec = Vec::with_capacity(2048); + let mut labs: Vec = Vec::new(); + let mut taken: Vec<[f32; 4]> = ui.blocked.clone(); // Terrain. if ui.filters[F_TERRAIN] { self.map_terrain(ui, &mut s); } self.map_grid(ui, &mut s); - // What covers what (editor). + // What covers what. let armed: Vec<(SamKind, DVec3)> = if editing { draft.sams.iter().filter(|d| !d.gone && d.kind.range() > 0.0).map(|d| (d.kind, d.pos)).collect() } else { self.sim.sams.iter().filter(|x| x.alive() && x.kind.range() > 0.0).map(|x| (x.kind, x.pos)).collect() }; - if editing { - let cell = 20.0f32; - let mut y = 0.0; - while y < h { - let mut x = 0.0; - while x < w { - let c = ui.to_world((x + cell / 2.0, y + cell / 2.0)); - let p = DVec3::new(c.0, 0.0, c.1); - let n = armed.iter().filter(|(k, pos)| reaches(*k, *pos, p)).count(); - if n > 0 { - s.push(fill(x, y, cell, cell, alpha(0x40FF60, if n == 1 { 0.10 } else { 0.18 }))); - } - x += cell; - } - y += cell; - } + if editing || ui.filters[F_TINT] { + self.map_tint(ui, &mut s, &draft, &armed); } - // Reach rings. + let picked = if editing { match ui.ed_sel { Some(EdSel::Sam(i)) => draft.sams.get(i).map(|d| d.pos), _ => None } } else { self.sel_pos(ui) }; + // Reach rings of the weapons. if ui.filters[F_RINGS] { let list: Vec<(SamKind, DVec3, bool)> = if editing { draft.sams.iter().filter(|d| !d.gone).map(|d| (d.kind, d.pos, d.id.is_none())).collect() @@ -1020,22 +1380,25 @@ impl AirDef { self.sim.sams.iter().filter(|x| x.alive()).map(|x| (x.kind, x.pos, false)).collect() }; let many = list.len() > 12; - let picked = if editing { match ui.ed_sel { Some(EdSel::Sam(i)) => draft.sams.get(i).map(|d| d.pos), _ => None } } else { self.sel_pos(ui) }; for (kind, pos, ghost) in list { let r = kind.range(); let chosen = picked.is_some_and(|p| p.distance(pos) < 1.0); - if r > 0.0 && (!many || chosen || ghost) { + if r > 0.0 && ui.sclass[sys_class(kind)] && (!many || chosen || ghost) { let c = ui.to_screen(pos.x, pos.z); ring(ui, &mut s, c, (r * ui.scale) as f32, 1.0, alpha(if ghost { 0xFFE070 } else { 0x40A0FF }, 0.75)); } } } + // The radars' coverage edges: what a low flyer is seen at, the land taken into account. + if ui.filters[F_VIEW] { + self.map_rims(ui, &mut s, &draft, picked); + } // The selected system's sensor, sector and guards. if let Some(i) = self.sel_sam(ui) { let sam = &self.sim.sams[i]; let c = ui.to_screen(sam.pos.x, sam.pos.z); let sens = sam.kind.spec().sensor; - if sens > 0.0 { + if sens > 0.0 && !ui.filters[F_VIEW] { ring(ui, &mut s, c, (sens * ui.scale) as f32, 1.0, alpha(0x80FF90, 0.45)); } self.map_sector(ui, &mut s, sam.pos, &sam.doctrine, sam.kind.range().max(200.0)); @@ -1065,68 +1428,15 @@ impl AirDef { } // Objects. if ui.filters[F_OBJECTS] { - let list: Vec<(usize, DVec3, bool)> = if editing { - draft.objs.iter().enumerate().filter(|(_, o)| !o.gone).map(|(i, o)| (i, o.pos, o.idx.is_none())).collect() - } else { - self.sim.targets.iter().enumerate().map(|(i, p)| (i, *p, false)).collect() - }; - for (i, p, ghost) in list { - let c = ui.to_screen(p.x, p.z); - if !ui.on_screen(c, 20.0) { - continue; - } - let covered = armed.iter().any(|(k, pos)| reaches(*k, *pos, p)); - let col = if !covered && (editing || !armed.is_empty()) { RED } else if ghost { YELLOW } else { WHITE }; - s.push(fill(c.0 - 4.0, c.1 - 4.0, 8.0, 8.0, col)); - s.push(fill(c.0 - 2.5, c.1 - 2.5, 5.0, 5.0, 0xFF101010)); - if !editing { - let hp = self.sim.target_health.get(i).copied().unwrap_or(100.0); - s.push(text(format!("{}{}", i + 1, if hp < 100.0 { format!(" {hp:.0}%") } else { String::new() }), c.0 + 6.0, c.1 - 4.0, col)); - } else if !covered { - s.push(text("не прикрыт", c.0 + 6.0, c.1 - 4.0, RED)); - } - if ui.sel == Some(Sel::Object(i)) && !editing { - s.extend(frame(c.0 - 8.0, c.1 - 8.0, 16.0, 16.0, 1.0, GREEN)); - } - } + self.map_objects(ui, &mut s, &draft, &armed, &mut labs, &mut taken); } // Systems. if ui.filters[F_SAMS] { - let list: Vec<(Option, SamKind, DVec3, f64, bool, bool, usize)> = if editing { - draft.sams.iter().enumerate().filter(|(_, d)| !d.gone).map(|(i, d)| (d.id, d.kind, d.pos, d.heading, d.id.is_none(), true, i)).collect() - } else { - self.sim.sams.iter().map(|x| (Some(x.id), x.kind, x.pos, x.heading, false, x.alive(), 0)).collect() - }; - for (id, kind, pos, heading, ghost, alive, di) in list { - let c = ui.to_screen(pos.x, pos.z); - if !ui.on_screen(c, 30.0) { - continue; - } - let col = if !alive { - 0xFF704040 - } else if ghost { - YELLOW - } else if kind.range() > 0.0 { - 0xFF40A0FF - } else { - 0xFF60D0A0 - }; - s.push(fill(c.0 - 4.0, c.1 - 4.0, 8.0, 8.0, col)); - let (dx, dz) = dir_of(heading); - s.push(line(c, (c.0 + dx as f32 * 11.0, c.1 + dz as f32 * 11.0), 1.5, col)); - if ui.scale > 0.12 && (self.sim.sams.len() <= 12 || ui.scale > 2.0) { - s.push(text(format!("{}", kind.name()), c.0 + 7.0, c.1 - 4.0, alpha(col, 0.95))); - } - let chosen = !editing && id.is_some() && ui.sel == id.map(Sel::Sam); - let ed = editing && ui.ed_sel == Some(EdSel::Sam(di)); - if chosen || ed { - s.extend(frame(c.0 - 8.0, c.1 - 8.0, 16.0, 16.0, 1.0, GREEN)); - } - } + self.map_sams(ui, &mut s, &draft, &mut labs, &mut taken); } // Launch sites the radars found. if ui.filters[F_SITES] { - self.map_sites(ui, &mut s); + self.map_sites(ui, &mut s, &mut labs, &mut taken); } // Own interceptors. for m in &self.sim.missiles { @@ -1137,7 +1447,7 @@ impl AirDef { } // The network's tracks. if ui.filters[F_TRACKS] || ui.filters[F_TRAILS] { - self.map_tracks(ui, &mut s); + self.map_tracks(ui, &mut s, &mut labs, &mut taken); } // The truth, when the sandbox shows it, marked as such. if self.sim.truth { @@ -1167,11 +1477,323 @@ impl AirDef { } else if editing && ui.pick != Pick::None { s.push(text("земля не загружена", ui.cursor.0 + 8.0, ui.cursor.1 + 8.0, RED)); } - self.map_panels(ui, &mut s, &draft); - s.truncate(8000); + place_labels((w, h), labs, taken, &mut s); + // The picture is cut, never the panels over it. + s.truncate(8000 - panels.len().min(900)); + s.extend(panels); s } + /// The objects: merged where they crowd, the number beside each. + fn map_objects(&self, ui: &mut MapUi, s: &mut Vec, draft: &Draft, armed: &[(SamKind, DVec3)], labs: &mut Vec, taken: &mut Vec<[f32; 4]>) { + let editing = ui.mode == Mode::Editor; + let list: Vec<(usize, DVec3, bool)> = if editing { + draft.objs.iter().enumerate().filter(|(_, o)| !o.gone).map(|(i, o)| (i, o.pos, o.idx.is_none())).collect() + } else { + self.sim.targets.iter().enumerate().map(|(i, p)| (i, *p, false)).collect() + }; + let vis: Vec<(usize, DVec3, bool, (f32, f32))> = list + .into_iter() + .map(|(i, p, g)| (i, p, g, ui.to_screen(p.x, p.z))) + .filter(|v| ui.on_screen(v.3, 20.0)) + .collect(); + let pos: Vec<(f32, f32)> = vis.iter().map(|v| v.3).collect(); + let solo: Vec = vis.iter().map(|v| ui.sel == Some(Sel::Object(v.0)) || ui.ed_sel == Some(EdSel::Obj(v.0)) && editing).collect(); + let covered = |p: DVec3| armed.iter().any(|(k, pos)| reaches(*k, *pos, p)); + for grp in groups_of(&pos, &solo, CLUSTER_R, !editing) { + if grp.len() > 1 { + let bare = grp.iter().any(|&k| !covered(vis[k].1)); + let col = if bare && !armed.is_empty() { RED } else { WHITE }; + let members: Vec<(f32, f32)> = grp.iter().map(|&k| vis[k].3).collect(); + draw_cluster(ui, s, taken, &members, col); + continue; + } + let (i, p, ghost, c) = vis[grp[0]]; + let cov = covered(p); + let col = if !cov && (editing || !armed.is_empty()) { RED } else if ghost { YELLOW } else { WHITE }; + s.push(fill(c.0 - 4.0, c.1 - 4.0, 8.0, 8.0, col)); + s.push(fill(c.0 - 2.5, c.1 - 2.5, 5.0, 5.0, 0xFF101010)); + taken.push([c.0 - 5.0, c.1 - 5.0, 10.0, 10.0]); + let chosen = ui.sel == Some(Sel::Object(i)) && !editing; + if !editing { + let hp = self.sim.target_health.get(i).copied().unwrap_or(100.0); + add_lab(ui, labs, format!("{}{}", i + 1, if hp < 100.0 { format!(" {hp:.0}%") } else { String::new() }), c, 6.0, col, chosen); + } else if !cov { + add_lab(ui, labs, "не прикрыт".into(), c, 6.0, RED, false); + } + if chosen { + s.extend(frame(c.0 - 8.0, c.1 - 8.0, 16.0, 16.0, 1.0, GREEN)); + } + } + } + + /// The systems: merged where they crowd, with short names; the filter by class applies. + fn map_sams(&self, ui: &mut MapUi, s: &mut Vec, draft: &Draft, labs: &mut Vec, taken: &mut Vec<[f32; 4]>) { + let editing = ui.mode == Mode::Editor; + // (id, kind, position, heading, ghost, alive, place in the draft, number) + let list: Vec<(Option, SamKind, DVec3, f64, bool, bool, usize, usize)> = if editing { + draft.sams.iter().enumerate().filter(|(_, d)| !d.gone).map(|(i, d)| (d.id, d.kind, d.pos, d.heading, d.id.is_none(), true, i, i + 1)).collect() + } else { + self.sim.sams.iter().enumerate().map(|(i, x)| (Some(x.id), x.kind, x.pos, x.heading, false, x.alive(), 0, i + 1)).collect() + }; + let vis: Vec<_> = list + .into_iter() + .filter(|v| editing || ui.sclass[sys_class(v.1)] || ui.sel == v.0.map(Sel::Sam)) + .map(|v| (v, ui.to_screen(v.2.x, v.2.z))) + .filter(|v| ui.on_screen(v.1, 30.0)) + .collect(); + let pos: Vec<(f32, f32)> = vis.iter().map(|v| v.1).collect(); + let solo: Vec = vis + .iter() + .map(|(v, _)| !editing && v.0.is_some() && ui.sel == v.0.map(Sel::Sam) || editing && ui.ed_sel == Some(EdSel::Sam(v.6))) + .collect(); + for grp in groups_of(&pos, &solo, CLUSTER_R, !editing) { + if grp.len() > 1 { + let members: Vec<(f32, f32)> = grp.iter().map(|&k| vis[k].1).collect(); + let col = if grp.iter().any(|&k| vis[k].0.1.armed()) { 0xFF40A0FF } else { 0xFF60D0A0 }; + draw_cluster(ui, s, taken, &members, col); + continue; + } + let ((id, kind, _, heading, ghost, alive, di, n), c) = vis[grp[0]]; + let col = if !alive { + 0xFF704040 + } else if ghost { + YELLOW + } else if kind.range() > 0.0 { + 0xFF40A0FF + } else { + 0xFF60D0A0 + }; + s.push(fill(c.0 - 4.0, c.1 - 4.0, 8.0, 8.0, col)); + let (dx, dz) = dir_of(heading); + s.push(line(c, (c.0 + dx as f32 * 11.0, c.1 + dz as f32 * 11.0), 1.5, col)); + taken.push([c.0 - 5.0, c.1 - 5.0, 10.0, 10.0]); + let chosen = !editing && id.is_some() && ui.sel == id.map(Sel::Sam); + let ed = editing && ui.ed_sel == Some(EdSel::Sam(di)); + let name = if chosen || ed || ui.scale > 1.5 { format!("{} {n}", short_name(kind)) } else { short_name(kind).to_string() }; + add_lab(ui, labs, name, c, 7.0, alpha(col, 0.95), chosen || ed); + if chosen || ed { + s.extend(frame(c.0 - 8.0, c.1 - 8.0, 16.0, 16.0, 1.0, GREEN)); + } + } + } + + /// The radars' coverage edges. A radar's edge is where the land first hides a low flyer along each ray round it; + /// each is worked out from the sim's own horizon (`low_rim`), a few a redraw, and kept a while. The selected + /// one shows its dead zones filled. + fn map_rims(&self, ui: &mut MapUi, s: &mut Vec, draft: &Draft, picked: Option) { + let editing = ui.mode == Mode::Editor; + let now = self.sim.tick; + // (the unit it is, kind, radar) + let list: Vec<(Option, SamKind, LowSensor, DVec3)> = if editing { + draft + .sams + .iter() + .filter(|d| !d.gone) + .filter_map(|d| { + // A unit that stays where it is keeps its horizon; a new or moved one has none yet. + let live = d.id.filter(|&id| self.sim.sams.iter().any(|x| x.id == id && x.pos.distance(d.pos) < 0.01)); + self.sim.low_sensor(d.kind, d.pos, live).map(|ls| (live, d.kind, ls, d.pos)) + }) + .collect() + } else { + self.sim.sams.iter().filter(|x| x.radar_up()).filter_map(|x| self.sim.low_sensor(x.kind, x.pos, Some(x.id)).map(|ls| (Some(x.id), x.kind, ls, x.pos))).collect() + }; + let list: Vec<_> = list.into_iter().filter(|v| ui.sclass[sys_class(v.1)] || picked.is_some_and(|p| p.distance(v.3) < 1.0)).collect(); + let (w, h) = ui.size; + let mid = (w / 2.0, h / 2.0); + let reach_px = mid.0.hypot(mid.1); + // Whether the circle comes near the screen at all. + let near_view = |ui: &MapUi, pos: DVec3, reach: f64| { + let c = ui.to_screen(pos.x, pos.z); + let r = (reach * ui.scale) as f32; + let far = (c.0 - mid.0).hypot(c.1 - mid.1); + r >= 3.0 && far - r <= reach_px && r - far <= reach_px + }; + // Work out the oldest few that are on the screen. + let mut stale: Vec<(u64, usize)> = Vec::new(); + for (i, (id, _, ls, pos)) in list.iter().enumerate() { + let Some(id) = id else { continue }; + if !near_view(ui, *pos, ls.reach) { + continue; + } + match ui.rims.get(id) { + Some(c) if c.pos == *pos && (c.reach - ls.reach).abs() < 0.5 && c.terrain == ls.terrain && now.saturating_sub(c.tick) < RIM_REFRESH => {} + Some(c) => stale.push((now.saturating_sub(c.tick) + if c.pos == *pos { 0 } else { 100_000 }, i)), + None => stale.push((u64::MAX, i)), + } + } + stale.sort_by(|a, b| b.0.cmp(&a.0)); + for (_, i) in stale.into_iter().take(RIMS_PER_FRAME) { + let (id, _, ls, pos) = &list[i]; + let d = self.sim.low_rim(&self.ground, ls); + ui.rims.insert(id.unwrap_or(u32::MAX), Rim { tick: now, pos: *pos, reach: ls.reach, terrain: ls.terrain, d }); + } + if ui.rims.len() > list.len() + 64 { + let live: std::collections::HashSet = self.sim.sams.iter().map(|x| x.id).collect(); + ui.rims.retain(|id, _| live.contains(id)); + } + // Draw. + let many = list.len() > 16; + let mut dashes = 0usize; + for (id, kind, ls, pos) in &list { + if !near_view(ui, *pos, ls.reach) { + continue; + } + let chosen = picked.is_some_and(|p| p.distance(*pos) < 1.0); + // A crowd of systems: only the selected one and the long-range radars show their edge. + if many && !chosen && sys_class(*kind) != 2 { + continue; + } + let cached = id.and_then(|i| ui.rims.get(&i)).filter(|c| c.pos == *pos && (c.reach - ls.reach).abs() < 0.5); + let full; + let d: &[f32] = match cached { + Some(c) => &c.d, + None => { + full = vec![ls.reach as f32; sim::RIM_RAYS]; + &full + } + }; + let base = match sys_class(*kind) { + 2 => 0x80FF90, + 1 => 0xFFC060, + _ => 0x60B0FF, + }; + let col = alpha(base, if chosen { 0.95 } else if many { 0.35 } else { 0.55 }); + let o = ui.to_screen(pos.x, pos.z); + let n = sim::RIM_RAYS; + let sub = if chosen || (!many && ls.reach * ui.scale > 160.0) { 2 } else { 1 }; + let at = |k: f32| { + // Ray k (fractional): the distance between the two rays it lies between. + let i = k.floor() as usize % n; + let j = (i + 1) % n; + let f = k - k.floor(); + let dist = (d[i] + (d[j] - d[i]) * f) as f64 * ui.scale; + let a = k as f64 / n as f64 * std::f64::consts::TAU; + (o.0 + (a.sin() * dist) as f32, o.1 + (a.cos() * dist) as f32) + }; + // A dead zone: the part of the circle the land takes away, filled, for the one in hand. + if chosen { + for i in 0..n { + let (j, r0, r1) = ((i + 1) % n, d[i], d[(i + 1) % n]); + let full_r = ls.reach as f32; + if r0 >= full_r - 1.0 && r1 >= full_r - 1.0 { + continue; + } + let edge = |k: usize, r: f32| { + let a = k as f64 / n as f64 * std::f64::consts::TAU; + (o.0 + (a.sin() * r as f64 * ui.scale) as f32, o.1 + (a.cos() * r as f64 * ui.scale) as f32) + }; + let (a0, a1, b0, b1) = (edge(i, r0), edge(i, full_r), edge(j, full_r), edge(j, r1)); + if ui.on_screen(a1, 80.0) || ui.on_screen(b1, 80.0) || ui.on_screen(a0, 80.0) { + s.push(Shape::Quad(QuadShape { corners: (pt(a0.0, a0.1), pt(a1.0, a1.1), pt(b0.0, b0.1), pt(b1.0, b1.1)), color: alpha(0xFF4040, 0.2) })); + } + } + } + for step in (0..n * sub).step_by(2) { + if dashes >= RIM_DASHES { + break; + } + let (k0, k1) = (step as f32 / sub as f32, (step + 1) as f32 / sub as f32); + let (p0, p1) = (at(k0), at(k1)); + if ui.on_screen(p0, 30.0) || ui.on_screen(p1, 30.0) { + s.push(line(p0, p1, if chosen { 1.5 } else { 1.0 }, col)); + dashes += 1; + } + } + } + } + + /// The coverage tint: a cell is green where a weapon reaches and a radar sees a low flyer over the land, amber where a + /// weapon reaches but no radar sees (the land hides it), blue where a radar sees and no weapon reaches. Kept while the + /// view and the layout stay the same. + fn map_tint(&self, ui: &mut MapUi, s: &mut Vec, draft: &Draft, armed: &[(SamKind, DVec3)]) { + let editing = ui.mode == Mode::Editor; + let (w, h) = ui.size; + let sensors: Vec = if editing { + draft + .sams + .iter() + .filter(|d| !d.gone) + .filter_map(|d| { + let live = d.id.filter(|&id| self.sim.sams.iter().any(|x| x.id == id && x.pos.distance(d.pos) < 0.01)); + self.sim.low_sensor(d.kind, d.pos, live) + }) + .collect() + } else { + self.sim.low_sensors() + }; + let mut key = 0xcbf29ce484222325u64; + for v in [ui.centre.0.round().to_bits(), ui.centre.1.round().to_bits(), ui.scale.to_bits(), w.to_bits() as u64, h.to_bits() as u64, self.sim.tick / 20, editing as u64, armed.len() as u64, sensors.len() as u64] { + key = (key ^ v).wrapping_mul(0x100000001b3); + } + for (_, p) in armed { + key = (key ^ p.x.to_bits() ^ p.z.to_bits().rotate_left(21)).wrapping_mul(0x100000001b3); + } + for ls in &sensors { + key = (key ^ ls.ant.x.to_bits() ^ ls.ant.z.to_bits().rotate_left(21) ^ ls.terrain as u64).wrapping_mul(0x100000001b3); + } + if ui.tint.key != key { + let cell = ((w * h / TINT_CELLS).sqrt()).max(14.0); + let tl = ui.to_world((0.0, 0.0)); + let br = ui.to_world((w, h)); + let near = |x: f64, z: f64, r: f64| x + r >= tl.0 && x - r <= br.0 && z + r >= tl.1 && z - r <= br.1; + let weap: Vec<(f64, f64, f64)> = armed.iter().filter(|(k, p)| near(p.x, p.z, k.range())).map(|(k, p)| (p.x, p.z, k.range() * k.range())).collect(); + let sens: Vec<&LowSensor> = sensors.iter().filter(|l| near(l.ant.x, l.ant.z, l.reach)).collect(); + let mut runs: Vec<([f32; 4], u32)> = Vec::new(); + let mut asks = 0usize; + let mut y = 0.0f32; + while y < h { + let mut x = 0.0f32; + let mut cur: Option<([f32; 4], u32)> = None; + while x < w { + let c = ui.to_world((x + cell / 2.0, y + cell / 2.0)); + let a = weap.iter().any(|(wx, wz, r2)| (wx - c.0).powi(2) + (wz - c.1).powi(2) <= *r2); + let mut b = false; + for l in &sens { + if (l.ant.x - c.0).powi(2) + (l.ant.z - c.1).powi(2) > l.reach * l.reach { + continue; + } + asks += 1; + if asks > TINT_ASKS { + break; + } + if self.sim.low_sees(&self.ground, l, c.0, c.1) { + b = true; + break; + } + } + let col = match (a, b) { + (true, true) => Some(alpha(0x40FF60, 0.12)), + (true, false) => Some(alpha(0xFFB030, 0.24)), + (false, true) => Some(alpha(0x4090FF, 0.07)), + _ => None, + }; + match (col, &mut cur) { + (Some(c), Some((r, rc))) if *rc == c => r[2] += cell, + (col, cur_run) => { + if let Some(done) = cur_run.take() { + runs.push(done); + } + if let Some(c) = col { + *cur_run = Some(([x, y, cell, cell], c)); + } + } + } + x += cell; + } + if let Some(done) = cur { + runs.push(done); + } + y += cell; + } + ui.tint = Tint { key, runs }; + } + for (r, c) in &ui.tint.runs { + s.push(fill(r[0], r[1], r[2], r[3], *c)); + } + } + fn map_terrain(&mut self, ui: &MapUi, s: &mut Vec) { let (w, h) = ui.size; let tl = ui.to_world((0.0, 0.0)); @@ -1254,7 +1876,7 @@ impl AirDef { s.push(line(o, at(c + half), 1.0, alpha(0xFFB040, 0.8))); } - fn map_sites(&self, ui: &MapUi, s: &mut Vec) { + fn map_sites(&self, ui: &mut MapUi, s: &mut Vec, labs: &mut Vec, taken: &mut Vec<[f32; 4]>) { let (w, h) = ui.size; for k in &self.sim.strikes.known { let Some(e) = k.est else { continue }; @@ -1268,7 +1890,8 @@ impl AirDef { if ui.on_screen(c, 20.0) { s.push(diamond(c.0, c.1, 5.0, col)); s.push(diamond(c.0, c.1, 2.5, 0xFF101010)); - s.push(text(format!("ПУ {}{}", k.site, if gone { " ×" } else { "" }), c.0 + 8.0, c.1 - 4.0, col)); + taken.push([c.0 - 6.0, c.1 - 6.0, 12.0, 12.0]); + add_lab(ui, labs, format!("ПУ {}{}", k.site, if gone { " ×" } else { "" }), c, 7.0, col, chosen); if chosen { s.extend(frame(c.0 - 9.0, c.1 - 9.0, 18.0, 18.0, 1.0, GREEN)); } @@ -1280,19 +1903,22 @@ impl AirDef { let len = (w.max(h) * 2.0) as f64; let b = (o.0 + (dx * len) as f32, o.1 + (dz * len) as f32); s.push(line(o, b, 1.0, alpha(0xFFA040, 0.35))); - // The ray's label where it crosses the screen's rim, near the origin when that is on the screen. - s.push(text(format!("ПУ {}: азимут {:.0}°", k.site, e.bearing.to_degrees()), (o.0 + dx as f32 * 40.0).clamp(4.0, w - 120.0), (o.1 + dz as f32 * 40.0).clamp(4.0, h - 10.0), alpha(0xFFA040, 0.9))); + // The ray's label near the origin when that is on the screen, else clamped to the rim. + let at = ((o.0 + dx as f32 * 40.0).clamp(30.0, w - 30.0), (o.1 + dz as f32 * 40.0).clamp(10.0, h - 10.0)); + add_lab(ui, labs, format!("ПУ {}: {:.0}°", k.site, e.bearing.to_degrees()), at, 0.0, alpha(0xFFA040, 0.9), chosen); } } } - fn map_tracks(&self, ui: &MapUi, s: &mut Vec) { + fn map_tracks(&self, ui: &mut MapUi, s: &mut Vec, labs: &mut Vec, taken: &mut Vec<[f32; 4]>) { let now = self.sim.tick; let pic = self.sim.picture(); - for (id, t) in &pic { - let chosen = ui.sel == Some(Sel::Track(*id)); + // Only the classes asked for (the selected one always). + let shown: Vec<(&u32, &Track)> = pic.iter().filter(|(id, t)| ui.tclass[tgt_class(t)] || ui.sel == Some(Sel::Track(**id))).collect(); + for (id, t) in &shown { + let chosen = ui.sel == Some(Sel::Track(**id)); if ui.filters[F_TRAILS] - && let Some(trail) = ui.trails.get(id) + && let Some(trail) = ui.trails.get(*id) { let col = alpha(0xFF6060, if chosen { 0.95 } else { 0.5 }); let mut prev: Option<(f32, f32)> = None; @@ -1309,7 +1935,6 @@ impl AirDef { if !ui.filters[F_TRACKS] { continue; } - let age = t.age(now); let forecast = t.forecast(now); let e = t.est(now); let c = ui.to_screen(e.x, e.z); @@ -1326,18 +1951,40 @@ impl AirDef { let ahead = ui.to_screen(e.x + v.x * 600.0, e.z + v.z * 600.0); dashed(s, c, ahead, alpha(0xFF8080, 0.8)); } - if ui.on_screen(c, 12.0) { - let col = if forecast { alpha(0xFFB0B0, 0.65) } else { 0xFFFF5050 }; - s.push(diamond(c.0, c.1, 4.0, col)); - if !forecast { - s.push(diamond(c.0, c.1, 2.0, 0xFF200808)); - } - if chosen || ui.scale > 0.5 { - s.push(text(format!("{}{}", if forecast { "~" } else { "" }, if age < 20 { "сейчас".to_string() } else { format!("{} с", age / 20) }), c.0 + 7.0, c.1 - 4.0, col)); - } - if chosen { - s.extend(frame(c.0 - 8.0, c.1 - 8.0, 16.0, 16.0, 1.0, GREEN)); - } + } + if !ui.filters[F_TRACKS] { + return; + } + let vis: Vec<(u32, &Track, (f32, f32))> = shown + .iter() + .map(|(id, t)| { + let e = t.est(now); + (**id, *t, ui.to_screen(e.x, e.z)) + }) + .filter(|v| ui.on_screen(v.2, 12.0)) + .collect(); + let pos: Vec<(f32, f32)> = vis.iter().map(|v| v.2).collect(); + let solo: Vec = vis.iter().map(|v| ui.sel == Some(Sel::Track(v.0))).collect(); + for grp in groups_of(&pos, &solo, CLUSTER_R * 0.8, true) { + if grp.len() > 1 { + let members: Vec<(f32, f32)> = grp.iter().map(|&k| vis[k].2).collect(); + draw_cluster(ui, s, taken, &members, RED); + continue; + } + let (id, t, c) = vis[grp[0]]; + let chosen = ui.sel == Some(Sel::Track(id)); + let forecast = t.forecast(now); + let age = t.age(now); + let col = if forecast { alpha(0xFFB0B0, 0.65) } else { 0xFFFF5050 }; + s.push(diamond(c.0, c.1, 4.0, col)); + if !forecast { + s.push(diamond(c.0, c.1, 2.0, 0xFF200808)); + } + taken.push([c.0 - 5.0, c.1 - 5.0, 10.0, 10.0]); + let txt = format!("{}{}", if forecast { "~" } else { "" }, if age < 20 { "сейчас".to_string() } else { format!("{} с", age / 20) }); + add_lab(ui, labs, txt, c, 5.0, col, chosen); + if chosen { + s.extend(frame(c.0 - 8.0, c.1 - 8.0, 16.0, 16.0, 1.0, GREEN)); } } } @@ -1348,66 +1995,198 @@ impl AirDef { let (w, h) = ui.size; let now = self.sim.tick; let editing = ui.mode == Mode::Editor; + let u = ui.u; + // Rows and buttons grow with the window; the text can't, so only the room round it does. + let bh = (14.0 * u).round(); + let lh = (11.0 * u).round(); + let narrow = w < 520.0; // The top bar: what the map shows, always said. - s.push(fill(0.0, 0.0, w, 14.0, PANEL)); - let head = if self.sim.truth { "КАРТА · ИСТИНА (песочница): красные рамки — настоящие дроны" } else { "КАРТА · режим: данные сети РЛС" }; - s.push(text(head, 6.0, 3.0, if self.sim.truth { YELLOW } else { GREEN })); - let cw = ui.to_world(ui.cursor); - let tail = format!("x {:.0} z {:.0}", cw.0, cw.1); - let tw = render::text_width(&tail); - s.push(text(tail, w - tw - 6.0, 3.0, GREY)); - // The left panel: filters and keys. - let mut y = 20.0; - s.push(fill(4.0, y - 3.0, 112.0, 14.0 * (FILTERS.len() as f32 + 1.0) + 8.0, PANEL)); - s.push(text("Слои (клавиши 1–7)", 8.0, y, GREY)); - y += 14.0; - for (i, name) in FILTERS.iter().enumerate() { - let on = ui.filters[i]; - let r = [6.0, y - 1.0, 108.0, 12.0]; - if hovered(ui, r) { - s.push(fill(r[0], r[1], r[2], r[3], 0x30FFFFFF)); - } - s.push(text(format!("{} {} {}", if on { "[x]" } else { "[ ]" }, i + 1, name), 9.0, y, if on { WHITE } else { GREY })); - ui.buttons.push(Btn { rect: r, act: Act::Filter(i) }); - y += 14.0; - } - y += 8.0; - // The mode buttons. - let mut bx = 6.0; - let button = |s: &mut Vec, ui: &mut MapUi, label: &str, act: Act, on: bool, x: f32, y: f32| -> f32 { - let bw = render::text_width(label) + 10.0; - let r = [x, y, bw, 14.0]; - let hv = hovered(ui, r); - s.push(fill(r[0], r[1], r[2], r[3], if on { 0xE0306040 } else if hv { 0xE0304038 } else { 0xE0182420 })); - s.extend(frame(r[0], r[1], r[2], r[3], 1.0, if on { GREEN } else { 0x60FFFFFF })); - s.push(text(label, x + 5.0, y + 3.0, WHITE)); - ui.buttons.push(Btn { rect: r, act }); - bw + 4.0 + s.push(fill(0.0, 0.0, w, bh, PANEL)); + ui.blocked.push([0.0, 0.0, w, bh]); + let tail = { + let cw = ui.to_world(ui.cursor); + format!("x {:.0} z {:.0}", cw.0, cw.1) }; - bx += button(s, ui, "Правка (E)", Act::Editor, editing, bx, y); - bx += button(s, ui, "Игрок (C)", Act::Player, false, bx, y); - button(s, ui, "Выход (M)", Act::Close, false, bx, y); - // The keys. - let help = if editing { - "ЛКМ: ставить/переносить · ПКМ: убрать · R · Z · Enter · Esc" + let tail_w = ui.tw(&tail); + let heads: [&str; 2] = if self.sim.truth { + ["КАРТА · ИСТИНА (песочница): красные рамки — настоящие дроны", "КАРТА · ИСТИНА"] } else { - "ЛКМ: выбор, тянуть: сдвиг · колесо: масштаб · ПКМ: марш · Esc" + ["КАРТА · режим: данные сети РЛС", "КАРТА · сеть РЛС"] }; - let hw = render::text_width(help); - s.push(fill(w / 2.0 - hw / 2.0 - 6.0, h - 16.0, hw + 12.0, 14.0, PANEL)); - s.push(text_c(help, w / 2.0, h - 13.0, GREY)); + let room = w - tail_w - 20.0; + let head = if ui.tw(heads[0]) <= room { heads[0] } else { heads[1] }; + s.push(text(head, 6.0, (bh - 8.0) / 2.0, if self.sim.truth { YELLOW } else { GREEN })); + if ui.tw(head) + tail_w + 20.0 <= w { + s.push(text(tail, w - tail_w - 6.0, (bh - 8.0) / 2.0, GREY)); + } + // The left panel: pages of layers, filters and scenarios. + let top = bh + 4.0; + let tabs_w = 14.0 + ["Слои", "Фильтр", "Сцена"].iter().map(|t| ui.tw(t) + 14.0).sum::(); + let lw = (if narrow { 150.0 } else { (w * 0.2).clamp(150.0, 190.0) }).max(tabs_w + 12.0); + let mut inner: Vec = Vec::new(); + let mut y = top; + if ui.folded { + let mut bx = 4.0; + bx += draw_btn(ui, &mut inner, "Панель", Act::Panel, false, bx, y, bh); + bx += draw_btn(ui, &mut inner, "Выход (M)", Act::Close, false, bx, y, bh); + ui.blocked.push([4.0, y, bx, bh]); + s.extend(inner); + } else { + let x0 = 8.0; + let iw = lw - 8.0; + let mut bx = x0; + bx += draw_btn(ui, &mut inner, "<", Act::Panel, false, bx, y, bh); + for (i, name) in ["Слои", "Фильтр", "Сцена"].iter().enumerate() { + bx += draw_btn(ui, &mut inner, name, Act::Tab(i), ui.tab == i, bx, y, bh); + } + y += bh + 5.0; + match ui.tab { + 0 => { + inner.push(text("Слои (клавиши 1–9)", x0, y, GREY)); + y += lh; + for (i, name) in FILTERS.iter().enumerate() { + let on = ui.filters[i]; + let r = [x0 - 2.0, y - 1.0, iw, lh]; + if hovered(ui, r) { + inner.push(fill(r[0], r[1], r[2], r[3], 0x30FFFFFF)); + } + inner.push(text(format!("{} {} {}", if on { "[x]" } else { "[ ]" }, i + 1, name), x0 + 1.0, y, if on { WHITE } else { GREY })); + ui.buttons.push(Btn { rect: r, act: Act::Filter(i) }); + y += lh; + } + } + 1 => { + let mut sc = [0usize; 4]; + for x in &self.sim.sams { + sc[sys_class(x.kind)] += 1; + } + let mut tc = [0usize; 5]; + for t in self.sim.picture().values() { + tc[tgt_class(t)] += 1; + } + inner.push(text("Системы по классу", x0, y, GREY)); + y += lh; + let mut x = x0; + for i in 0..SYS_CLASSES.len() { + let label = format!("{} {}", SYS_CLASSES[i], sc[i]); + let bw = ui.tw(&label) + 10.0; + if x + bw > lw { + x = x0; + y += bh + 3.0; + } + x += draw_btn(ui, &mut inner, &label, Act::SysClass(i), ui.sclass[i], x, y, bh); + } + y += bh + 6.0; + inner.push(text("Цели сети по скорости", x0, y, GREY)); + y += lh; + x = x0; + for i in 0..TGT_CLASSES.len() { + let label = format!("{} {}", TGT_CLASSES[i], tc[i]); + let bw = ui.tw(&label) + 10.0; + if x + bw > lw { + x = x0; + y += bh + 3.0; + } + x += draw_btn(ui, &mut inner, &label, Act::TgtClass(i), ui.tclass[i], x, y, bh); + } + y += bh + 5.0; + for l in ["ЗРК ракеты · ЗАК пушки · тыл", "Д дроны · Г реактивные · К крылатые", "С сверхзвук · Б баллистика"] { + inner.push(text(l, x0, y, GREY)); + y += lh - 1.0; + } + } + _ => { + inner.push(text("Имя сценария", x0, y, GREY)); + y += lh; + let r = [x0 - 2.0, y, iw, bh]; + inner.push(fill(r[0], r[1], r[2], r[3], if ui.name_focus { 0xE0302810 } else { 0xE0101814 })); + inner.extend(frame(r[0], r[1], r[2], r[3], 1.0, if ui.name_focus { YELLOW } else { 0x60FFFFFF })); + let shown = format!("{}{}", ui.name, if ui.name_focus { "_" } else { "" }); + inner.push(text(shown, x0 + 3.0, y + (bh - 8.0) / 2.0, WHITE)); + ui.buttons.push(Btn { rect: r, act: Act::NameFocus }); + y += bh + 3.0; + let over = matches!(&ui.confirm, Some((Act::SaveScn, _))); + let save = if over { "Перезаписать?" } else { "Сохранить" }; + draw_btn(ui, &mut inner, save, Act::SaveScn, over, x0 - 2.0, y, bh); + y += bh + 5.0; + if ui.scn.is_empty() { + inner.push(text("Сохранённых нет", x0, y, GREY)); + y += lh; + } else { + inner.push(text(format!("Сохранённые: {}", ui.scn.len()), x0, y, GREY)); + y += lh; + let first = ui.scn_page * SCN_ROWS; + for i in first..(first + SCN_ROWS).min(ui.scn.len()) { + let nm = ui.scn[i].clone(); + let load = matches!(&ui.confirm, Some((Act::LoadScn(k), _)) if *k == i); + let r = [x0 - 2.0, y, iw, bh]; + let hv = hovered(ui, r); + inner.push(fill(r[0], r[1], r[2], r[3], if load { 0xE0806020 } else if hv { 0xE0304038 } else { 0xE0182420 })); + inner.extend(frame(r[0], r[1], r[2], r[3], 1.0, if load { YELLOW } else { 0x60FFFFFF })); + let label = if load { "ещё раз".to_string() } else { nm }; + inner.push(text(label, r[0] + 4.0, y + (bh - 8.0) / 2.0, WHITE)); + ui.buttons.push(Btn { rect: r, act: Act::LoadScn(i) }); + y += bh + 2.0; + } + if ui.scn.len() > SCN_ROWS { + let pages = ui.scn.len().div_ceil(SCN_ROWS); + let mut bx = x0 - 2.0; + bx += draw_btn(ui, &mut inner, "<", Act::ScnPage(-1), false, bx, y, bh); + bx += draw_btn(ui, &mut inner, ">", Act::ScnPage(1), false, bx, y, bh); + inner.push(text(format!("{}/{}", ui.scn_page + 1, pages), bx + 4.0, y + (bh - 8.0) / 2.0, GREY)); + y += bh + 2.0; + } + } + } + } + y += 6.0; + // The mode buttons, in rows that wrap. + let mut bx = x0 - 2.0; + for (label, act, on) in [("Правка (E)", Act::Editor, editing), ("Игрок (C)", Act::Player, false), ("Выход (M)", Act::Close, false)] { + let bw = ui.tw(label) + 10.0; + if bx + bw > lw + 2.0 { + bx = x0 - 2.0; + y += bh + 3.0; + } + bx += draw_btn(ui, &mut inner, label, act, on, bx, y, bh); + } + y += bh + 4.0; + let panel = [4.0, top - 3.0, lw, y - top + 3.0]; + s.push(fill(panel[0], panel[1], panel[2], panel[3], PANEL)); + s.extend(inner); + ui.blocked.push(panel); + } + // The keys. + let helps: [&str; 3] = if editing { + ["ЛКМ: ставить/переносить · ПКМ: убрать · R · Z · Enter · Esc", "ЛКМ ставить · ПКМ убрать · Enter · Esc", "Enter · Esc"] + } else { + ["ЛКМ: выбор, тянуть: сдвиг · колесо: масштаб · ПКМ: марш · Esc", "ЛКМ выбор · колесо масштаб · Esc", "Esc"] + }; + for help in helps { + let hw = ui.tw(help); + if hw + 12.0 <= w - 24.0 { + let r = [w / 2.0 - hw / 2.0 - 6.0, h - bh - 2.0, hw + 12.0, bh]; + s.push(fill(r[0], r[1], r[2], r[3], PANEL)); + s.push(text_c(help, w / 2.0, r[1] + (bh - 8.0) / 2.0, GREY)); + ui.blocked.push(r); + break; + } + } // The note. if let Some((n, until)) = ui.note.clone() { if now < until { - let nw = render::text_width(&n); - s.push(fill(w / 2.0 - nw / 2.0 - 8.0, 22.0, nw + 16.0, 16.0, 0xE0302010)); - s.push(text_c(n, w / 2.0, 26.0, YELLOW)); + let nw = ui.tw(&n).min(w - 20.0); + let r = [w / 2.0 - nw / 2.0 - 8.0, top, nw + 16.0, bh + 2.0]; + s.push(fill(r[0], r[1], r[2], r[3], 0xE0302010)); + s.push(text_c(n, w / 2.0, top + (bh - 6.0) / 2.0, YELLOW)); + ui.blocked.push(r); } else { ui.note = None; } } // The right card. - let cx = w - CARD_W - 2.0; + let card_w = if narrow { 150.0 } else { (w * 0.2).clamp(170.0, 250.0) }; + let cx = w - card_w - 2.0; let mut lines: Vec<(String, u32)> = Vec::new(); let mut btns: Vec<(String, Act, bool)> = Vec::new(); if editing { @@ -1421,7 +2200,7 @@ impl AirDef { let mut cur = String::new(); for word in l.split(' ') { let try_ = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") }; - if render::text_width(&try_) > CARD_W - 8.0 && !cur.is_empty() { + if ui.tw(&try_) > card_w - 8.0 && !cur.is_empty() { wrapped.push((std::mem::take(&mut cur), c)); cur = word.to_string(); } else { @@ -1432,50 +2211,46 @@ impl AirDef { } let lines = wrapped; if !lines.is_empty() || !btns.is_empty() { - let mut y = 18.0; - let ch = lines.len() as f32 * 11.0 + btns.len() as f32 * 0.0 + 10.0; + let mut y = top; + let ch = lines.len() as f32 * lh + 10.0; // Buttons go in rows that wrap. let mut rows: Vec> = vec![Vec::new()]; let mut used = 0.0; for (l, a, on) in btns { - let bw = render::text_width(&l) + 10.0; - if used + bw > CARD_W - 6.0 && !rows.last().unwrap().is_empty() { + let bw = ui.tw(&l) + 10.0; + if used + bw > card_w - 6.0 && !rows.last().unwrap().is_empty() { rows.push(Vec::new()); used = 0.0; } rows.last_mut().unwrap().push((l, a, on, bw)); used += bw + 4.0; } - let total = ch + rows.iter().filter(|r| !r.is_empty()).count() as f32 * 18.0 + 4.0; - s.push(fill(cx - 3.0, y - 3.0, CARD_W + 2.0, total, PANEL)); + let total = ch + rows.iter().filter(|r| !r.is_empty()).count() as f32 * (bh + 4.0) + 4.0; + let panel = [cx - 3.0, y - 3.0, card_w + 2.0, total]; + s.push(fill(panel[0], panel[1], panel[2], panel[3], PANEL)); + ui.blocked.push(panel); for (l, c) in &lines { s.push(text(l.as_str(), cx, y, *c)); - y += 11.0; + y += lh; } y += 6.0; for row in rows { let mut x = cx; - for (l, a, on, bw) in row { - let r = [x, y, bw, 14.0]; - let hv = hovered(ui, r); - s.push(fill(r[0], r[1], r[2], r[3], if on { 0xE0306040 } else if hv { 0xE0304038 } else { 0xE0182420 })); - s.extend(frame(r[0], r[1], r[2], r[3], 1.0, if on { GREEN } else { 0x60FFFFFF })); - s.push(text(l.as_str(), x + 5.0, y + 3.0, WHITE)); - ui.buttons.push(Btn { rect: r, act: a }); - x += bw + 4.0; + for (l, a, on, _) in row { + x += draw_btn(ui, s, &l, a, on, x, y, bh); } - y += 18.0; + y += bh + 4.0; } } // The editor's palette along the bottom. if editing { - let mut kinds: Vec<(String, Pick)> = SamKind::ALL.iter().map(|k| (k.name().to_string(), Pick::Sam(*k))).collect(); + let mut kinds: Vec<(String, Pick)> = SamKind::ALL.iter().map(|k| (short_name(*k).to_string(), Pick::Sam(*k))).collect(); kinds.push(("Объект".into(), Pick::Object)); // Rows first, so the palette sits just above the help line. let mut rows = 1; let mut used = 6.0; for (label, _) in &kinds { - let bw = render::text_width(label) + 10.0; + let bw = ui.tw(label) + 10.0; if used + bw > w - 4.0 { rows += 1; used = 6.0; @@ -1483,20 +2258,22 @@ impl AirDef { used += bw + 4.0; } let mut x = 6.0; - let mut y = h - 18.0 - rows as f32 * 16.0; - s.push(fill(0.0, y - 3.0, w, rows as f32 * 16.0 + 4.0, PANEL)); + let mut y = h - bh - 4.0 - rows as f32 * (bh + 2.0); + let panel = [0.0, y - 3.0, w, rows as f32 * (bh + 2.0) + 4.0]; + s.push(fill(panel[0], panel[1], panel[2], panel[3], PANEL)); + ui.blocked.push(panel); for (label, pick) in kinds { - let bw = render::text_width(&label) + 10.0; + let bw = ui.tw(&label) + 10.0; if x + bw > w - 4.0 { x = 6.0; - y += 16.0; + y += bh + 2.0; } let on = ui.pick == pick; - let r = [x, y, bw, 14.0]; + let r = [x, y, bw, bh]; let hv = hovered(ui, r); s.push(fill(r[0], r[1], r[2], r[3], if on { 0xE0806020 } else if hv { 0xE0304038 } else { 0xE0182420 })); s.extend(frame(r[0], r[1], r[2], r[3], 1.0, if on { YELLOW } else { 0x60FFFFFF })); - s.push(text(label, x + 5.0, y + 3.0, WHITE)); + s.push(text(label, x + 5.0, y + (bh - 8.0) / 2.0, WHITE)); ui.buttons.push(Btn { rect: r, act: Act::Pick(pick) }); x += bw + 4.0; } @@ -1695,6 +2472,18 @@ fn sim_angle_diff(from: f64, to: f64) -> f64 { (to - from + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI } +/// A button at (x, y) as high as `bh`; registers its click and returns the room it takes (with the gap after it). +fn draw_btn(ui: &mut MapUi, s: &mut Vec, label: &str, act: Act, on: bool, x: f32, y: f32, bh: f32) -> f32 { + let bw = ui.tw(label) + 10.0; + let r = [x, y, bw, bh]; + let hv = hovered(ui, r); + s.push(fill(r[0], r[1], r[2], r[3], if on { 0xE0306040 } else if hv { 0xE0304038 } else { 0xE0182420 })); + s.extend(frame(r[0], r[1], r[2], r[3], 1.0, if on { GREEN } else { 0x60FFFFFF })); + s.push(text(label, x + 5.0, y + (bh - 8.0) / 2.0, WHITE)); + ui.buttons.push(Btn { rect: r, act }); + bw + 4.0 +} + fn hovered(ui: &MapUi, r: [f32; 4]) -> bool { ui.cursor.0 >= r[0] && ui.cursor.0 <= r[0] + r[2] && ui.cursor.1 >= r[1] && ui.cursor.1 <= r[1] + r[3] } @@ -1732,3 +2521,59 @@ fn dashed(s: &mut Vec, a: (f32, f32), b: (f32, f32), color: u32) { s.push(line((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1), 1.0, color)); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn labels(n: usize) -> (Vec, Vec<(f32, f32)>) { + // 128 systems of a polygon in a few clumps, each with the long name that overlapped before. + let pos: Vec<(f32, f32)> = (0..n).map(|i| (300.0 + (i % 16) as f32 * 14.0 + (i / 64) as f32 * 3.0, 200.0 + (i / 16) as f32 * 13.0)).collect(); + let labs = pos + .iter() + .enumerate() + .map(|(i, p)| Lab { text: "РЛС дальнего обнаружения".into(), w: 24.0 * 6.0, at: *p, r: 7.0, col: WHITE, prio: ((p.0 - 400.0).hypot(p.1 - 250.0)) + i as f32 * 0.001 }) + .collect(); + (labs, pos) + } + + #[test] + fn labels_never_overlap_and_are_capped() { + let (labs, pos) = labels(128); + let mut taken: Vec<[f32; 4]> = pos.iter().map(|p| [p.0 - 5.0, p.1 - 5.0, 10.0, 10.0]).collect(); + taken.push([0.0, 0.0, 1000.0, 14.0]); + let mut out = Vec::new(); + let t = std::time::Instant::now(); + place_labels((1000.0, 560.0), labs, taken.clone(), &mut out); + eprintln!("128 labels placed in {:?}, {} shown", t.elapsed(), out.len()); + assert!(out.len() <= MAX_LABELS); + let rects: Vec<[f32; 4]> = out + .iter() + .map(|s| match s { + Shape::Text(t) => [t.at.x, t.at.y, 144.0, LH], + _ => unreachable!(), + }) + .collect(); + for (i, a) in rects.iter().enumerate() { + assert!(taken.iter().all(|m| !overlap(*a, *m)), "label {i} covers a marker or panel"); + for b in &rects[i + 1..] { + assert!(!overlap(*a, *b), "two labels overlap"); + } + } + } + + #[test] + fn crowded_markers_merge_and_the_selected_one_stays_alone() { + let pos: Vec<(f32, f32)> = (0..128).map(|i| (300.0 + (i % 16) as f32 * 3.0, 200.0 + (i / 16) as f32 * 3.0)).collect(); + let mut solo = vec![false; 128]; + solo[40] = true; + let t = std::time::Instant::now(); + let g = groups_of(&pos, &solo, CLUSTER_R, true); + eprintln!("128 markers grouped in {:?} into {}", t.elapsed(), g.len()); + assert!(g.len() < 20 && g.iter().any(|x| x == &vec![40])); + assert_eq!(g.iter().map(Vec::len).sum::(), 128); + // Zoomed in, they stand apart. + let far: Vec<(f32, f32)> = (0..128).map(|i| (i as f32 * 30.0, 0.0)).collect(); + assert_eq!(groups_of(&far, &vec![false; 128], CLUSTER_R, true).len(), 128); + } +} diff --git a/airdef-mod/src/sim/balance.rs b/airdef-mod/src/sim/balance.rs index 8d98ba8..50e9aa1 100644 --- a/airdef-mod/src/sim/balance.rs +++ b/airdef-mod/src/sim/balance.rs @@ -1,7 +1,6 @@ //! Balance measurements: a whole raid on flat land, flown headlessly against a cluster of systems. //! The `#[ignore]`d tests print tables (`cargo test --release balance -- --ignored --nocapture`); //! the others assert the cheap invariants. The tables are what the air defence's tuning is judged by. -use super::sites::testland::{Land, serve}; use super::*; /// The ten armed kinds in the order a cluster takes them (long-range heavy ones spread evenly). @@ -48,12 +47,19 @@ pub(super) struct Result { pub untracked: u32, pub hits: u32, pub crashes: u32, + /// Interceptors lost against the land, and ones that blew up short of a ridge. + pub blocked: u32, + pub scrubbed: u32, pub unshot: u32, pub missed: u32, /// Missiles fired by the heavy systems (S-400, S-500, Patriot). pub heavy: u32, pub by_kind: Vec<(DroneKind, u32, u32)>, pub tally: String, + /// Drones that vanished short of their aim without being shot down: kind, where, how far from the aim. + pub crashed: Vec<(DroneKind, DVec3, f64)>, + /// Chunk tickets the sim asked for. + pub tickets: usize, } pub(super) struct Setup { @@ -65,20 +71,121 @@ pub(super) struct Setup { pub objects: usize, /// Called on the sim before the raid (a test sets its switches). pub tweak: fn(&mut Sim), + /// Hilly land instead of flat (`hills`). + pub hilly: bool, + /// Only a disc of this radius round the player (the origin) is loaded, as in the game; beyond it the land is not + /// there (`Void`) unless a chunk ticket was asked for (loaded after a delay, for a while). + pub disc: Option, + /// Mountains on top of the hills (ridges of up to 150 blocks, 600-900 blocks apart). + pub mountains: bool, + /// How far out the raid launches (the order's distance). + pub dist: f64, + /// Systems and objects sunk this far into the land (a player who stood inside it). + pub bury: f64, +} + +/// Rolling hills: ridges of 20-35 blocks over a plain at 64, wavelengths of 90-250 blocks. +pub(super) fn hills(x: i32, z: i32) -> i32 { + let (x, z) = (f64::from(x), f64::from(z)); + let n = (x / 53.0 + (z / 91.0).sin() * 1.7).sin() * 0.5 + (z / 41.0 - (x / 77.0).cos()).sin() * 0.35 + ((x + z) / 130.0).sin() * 0.4 + ((x - 2.0 * z) / 29.0).sin() * 0.12; + 64 + (n * 28.0 + 14.0).max(-4.0) as i32 +} + +fn ground_at(hilly: bool, x: f64, z: f64) -> f64 { + if hilly { f64::from(hills(x as i32, z as i32)) } else { 64.0 } +} + +/// Mountains: broad ridges over the hills, nothing within about 300 blocks of the origin. +pub(super) fn mountains(x: i32, z: i32) -> i32 { + let (x, z) = (f64::from(x), f64::from(z)); + let r = (x * x + z * z).sqrt(); + let ridge = ((x / 410.0 + 0.7).sin() * (z / 530.0 - 0.4).sin() + (x / 700.0 - z / 610.0).sin() * 0.6).max(0.0); + (ridge * 150.0 * ((r - 300.0) / 400.0).clamp(0.0, 1.0)) as i32 +} + +/// The game's view of the land: all of it, or a disc round the player and the chunks asked for. +pub(super) struct Loader { + pub hilly: bool, + pub mountains: bool, + pub disc: Option, + asked: std::cell::RefCell>, + pub tickets: usize, +} +const TICKET_DELAY: u64 = 40; +const TICKET_LIFE: u64 = 400; + +impl Loader { + pub fn new(s: &Setup) -> Self { + Loader { hilly: s.hilly, mountains: s.mountains, disc: s.disc, asked: Default::default(), tickets: 0 } + } + pub fn height(&self, x: i32, z: i32) -> i32 { + if self.hilly { hills(x, z) + if self.mountains { mountains(x, z) } else { 0 } } else { 64 } + } + fn loaded(&self, c: (i32, i32), now: u64) -> Option { + let Some(r) = self.disc else { return Some(true) }; + let (x, z) = (f64::from(c.0 * 16 + 8), f64::from(c.1 * 16 + 8)); + if (x * x + z * z).sqrt() <= r { + return Some(true); + } + match self.asked.borrow().get(&c) { + Some(&(since, until)) if now < until => Some(now >= since + TICKET_DELAY), + _ => None, + } + } + pub fn serve(&mut self, ground: &mut Ground, now: u64) { + for c in ground.take_chunks() { + self.tickets += 1; + if std::env::var("NOTICKETS").is_err() { + let mut asked = self.asked.borrow_mut(); + let e = asked.entry(c).or_insert((now, now)); + if now >= e.1 { + *e = (now, now); + } + e.1 = now + TICKET_DELAY + TICKET_LIFE; + } + } + let cols = ground + .take_wanted() + .into_iter() + .map(|(x, z)| { + let y = match self.loaded((x >> 4, z >> 4), now) { + Some(true) => self.height(x, z), + Some(false) => PENDING, + None => -64, + }; + (x, z, y) + }) + .collect(); + ground.answer(cols, now); + let secs = ground.world.take_wanted(now); + let view = Seen { l: self, now }; + ground.world.serve(&view, secs, now); + } +} +struct Seen<'a> { + l: &'a Loader, + now: u64, +} +impl world::WorldSource for Seen<'_> { + fn solid(&self, x: i32, y: i32, z: i32) -> Option { + self.l.loaded((x >> 4, z >> 4), self.now).filter(|&l| l).map(|_| y < self.l.height(x, z)) + } } impl Setup { pub fn new(systems: usize, smart: bool, seed: u64) -> Self { - Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {} } + Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {}, hilly: false, bury: 0.0, disc: None, mountains: false, dist: 500.0 } } } /// The cluster: `objects` in a grid, systems of the mix scattered round them, radars among them. pub(super) fn cluster(sim: &mut Sim, s: &Setup) { + let y = |x: f64, z: f64| if s.hilly { ground_at(true, x, z) - s.bury } else { 70.0 }; let mut rng = Rng::new(s.seed.wrapping_mul(977) + 5); let side = (s.objects as f64).sqrt().ceil() as usize; for k in 0..s.objects { - let p = DVec3::new(((k % side) as f64 - side as f64 / 2.0) * 70.0, 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0); + let (px, pz) = (((k % side) as f64 - side as f64 / 2.0) * 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0); + let p = DVec3::new(px, if s.hilly { y(px, pz) + 6.0 + s.bury } else { 70.0 }, pz); sim.targets.push(p); sim.target_health.push(100.0); } @@ -88,13 +195,15 @@ pub(super) fn cluster(sim: &mut Sim, s: &Setup) { let a = rng.f() * TAU; let r = 30.0 + rng.f() * 110.0; let id = sim.id(); - sim.sams.push(Sam::new(id, MIX[i % MIX.len()], at + DVec3::new(a.sin() * r, 0.0, a.cos() * r), rng.f() * TAU)); + let (px, pz) = (at.x + a.sin() * r, at.z + a.cos() * r); + sim.sams.push(Sam::new(id, MIX[i % MIX.len()], DVec3::new(px, y(px, pz), pz), rng.f() * TAU)); } for _ in 0..s.radars { let a = rng.f() * TAU; let r = rng.f() * 220.0; let id = sim.id(); - sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(a.sin() * r, 70.0, a.cos() * r), 0.0)); + let (px, pz) = (a.sin() * r, a.cos() * r); + sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(px, if s.hilly { y(px, pz) } else { 70.0 }, pz), 0.0)); } } @@ -104,13 +213,13 @@ pub(super) fn fly(s: &Setup) -> Result { cluster(&mut sim, s); (s.tweak)(&mut sim); let mut ground = Ground::default(); - let land = Land(|_, _| 64); + let mut land = Loader::new(s); let mut out = Out::default(); for _ in 0..200 { - serve(&mut ground, &land, sim.tick); + land.serve(&mut ground, sim.tick); sim.step(&mut ground, None, &mut out); } - let mut order = crate::wave::WaveConfig::mix(s.size, 500.0); + let mut order = crate::wave::WaveConfig::mix(s.size, s.dist); // The Oreshnik's blocks are a raid of their own; this measures the ordinary mass raid. order.kinds.retain(|k| k.0 != DroneKind::Oreshnik); if s.smart { @@ -125,7 +234,7 @@ pub(super) fn fly(s: &Setup) -> Result { let start = sim.tick; let mut gone_unkilled: Vec = Vec::new(); for _ in 0..9000 { - serve(&mut ground, &land, sim.tick); + land.serve(&mut ground, sim.tick); out = Out::default(); sim.step(&mut ground, None, &mut out); let now = sim.tick; @@ -184,6 +293,8 @@ pub(super) fn fly(s: &Setup) -> Result { break; } } + result.tickets = land.tickets; + result.crashed = sim.crashed.iter().map(|c| (c.0, c.1, c.1.distance(c.2))).collect(); result.ticks = sim.tick - start; result.launched = sim.launched; result.kills = sim.kills; @@ -191,6 +302,8 @@ pub(super) fn fly(s: &Setup) -> Result { result.heavy = sim.sams.iter().filter(|m| matches!(m.kind, SamKind::S400 | SamKind::S500 | SamKind::Patriot)).map(|m| m.fired).sum(); result.hits = sim.hits; result.crashes = sim.crashes; + result.blocked = sim.tally.blocked; + result.scrubbed = sim.tally.scrubbed; result.leaked = sim.hits; let mut kinds: HashMap = HashMap::new(); for h in &gone_unkilled { @@ -215,33 +328,60 @@ pub(super) fn fly(s: &Setup) -> Result { } pub(super) fn table(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim)) -> Vec<(usize, f64)> { + table_on(title, smart, size, systems, seeds, tweak, std::env::var("HILLS").is_ok()) +} + +pub(super) fn table_on(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim), hilly: bool) -> Vec<(usize, f64)> { let mut means = Vec::new(); - eprintln!("== {title} ({} raid)", if smart { "smart" } else { "mix" }); - eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash", "sams", "seed", "sent", "kills", "leak", "missil", "heavy"); + eprintln!("== {title} ({} raid{})", if smart { "smart" } else { "mix" }, if hilly { ", hills" } else { "" }); + eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash blkd scrub", "sams", "seed", "sent", "kills", "leak", "missil", "heavy"); for &n in systems { - let (mut total, mut missiles, mut heavy, mut clean) = (0, 0, 0, 0); + let (mut total, mut missiles, mut heavy, mut clean, mut crashes, mut blocked) = (0, 0, 0, 0, 0, 0); for &seed in seeds { let mut s = Setup::new(n, smart, seed); s.tweak = tweak; s.size = size; + s.hilly = hilly; let r = fly(&s); total += r.leaked; clean += u32::from(r.leaked == 0); + crashes += r.crashes; + blocked += r.blocked + r.scrubbed; missiles += r.missiles; heavy += r.heavy; - if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::>()); } + if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.blocked, r.scrubbed, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::>()); } if seed == seeds[0] && std::env::var("ROWS").is_ok() { eprintln!(" tally: {}", r.tally); } } let mean = total as f64 / seeds.len() as f64; let k = seeds.len() as f64; - eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}", seeds.len(), missiles as f64 / k, heavy as f64 / k); + eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}, drone crashes {:.1}, interceptors lost to land {:.1}", seeds.len(), missiles as f64 / k, heavy as f64 / k, crashes as f64 / k, blocked as f64 / k); means.push((n, mean)); } means } +#[test] +#[ignore] +fn buried_table() { + for n in [20usize, 150] { + for seed in 1..=4u64 { + let mut s = Setup::new(n, false, seed); + s.hilly = true; + s.bury = 7.0; + let r = fly(&s); + eprintln!("buried {n} seed {seed}: sent {} kills {} leaked {} crashes {} blocked {} missiles {}", r.launched, r.kills, r.leaked, r.crashes, r.blocked, r.missiles); + } + } +} + +#[test] +#[ignore] +fn hills_table() { + table_on("hills", false, 36, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |_| {}, true); +} + #[test] #[ignore] fn raid_table() { @@ -265,6 +405,46 @@ fn a_small_cluster_loses_and_a_big_one_holds() { assert!(big.leaked <= 5, "150 systems leaked {}", big.leaked); } +/// The same on rolling hills: 150 systems still hold about as on flat land, drones do not fly into the land on +/// their own, and the interceptors do not routinely strike it (a few that blow up short of a ridge are allowed). +#[test] +fn on_hills_a_big_cluster_holds_and_nothing_routinely_hits_the_land() { + let mut s = Setup::new(150, false, 2); + s.size = 80; + s.hilly = true; + let big = fly(&s); + let mut s = Setup::new(20, false, 2); + s.size = 80; + s.hilly = true; + let small = fly(&s); + eprintln!("hills, 20 systems: {small:?}\nhills, 150 systems: {big:?}"); + assert!(small.leaked >= 10, "20 systems should clearly lose on hills too, leaked {}", small.leaked); + assert!(big.leaked <= 6, "150 systems on hills leaked {}", big.leaked); + assert!(big.crashes <= 2, "drones flew into the hills: {}", big.crashes); + assert!(big.blocked * 20 <= big.missiles, "interceptors struck the land: {} of {}", big.blocked, big.missiles); +} + +/// A raid from 2500 blocks over mountains, with only a disc round the player known (and chunks that load on request): +/// the flights do not fly into the land by themselves (a rare one that cannot climb a ridge in time is allowed). +#[test] +fn far_raids_over_mountains_do_not_crash_into_them() { + let (mut flown, mut crashed) = (0, 0); + for seed in [3u64, 4, 8] { + for smart in [false, true] { + let mut s = Setup::new(50, smart, seed); + s.hilly = true; + s.mountains = true; + s.dist = 2500.0; + s.disc = Some(300.0); + let r = fly(&s); + eprintln!("seed {seed} smart {smart}: {} crashed of {}: {:?}", r.crashed.len(), r.launched, r.crashed.iter().map(|c| c.0).collect::>()); + flown += r.launched; + crashed += r.crashed.len() as u32; + } + } + assert!(crashed * 40 <= flown, "{crashed} of {flown} flew into the land"); +} + /// What each of the new rules adds, one at a time (the raid of 36 and a heavier one of 80). #[test] #[ignore] @@ -295,3 +475,36 @@ fn one_raid() { eprintln!("seed {seed}: sent {} kills {} leaked {} missiles {} ticks {}", r.launched, r.kills, r.leaked, r.missiles, r.ticks); } } + +/// Far launches over mountains where only a disc round the player is known: who crashes, where and how often. +/// `cargo test --release far_table -- --ignored --nocapture` (DISC=300 sets the radius, 0 for all known). +#[test] +#[ignore] +fn far_table() { + let disc: f64 = std::env::var("DISC").ok().and_then(|v| v.parse().ok()).unwrap_or(300.0); + let dist: f64 = std::env::var("DIST").ok().and_then(|v| v.parse().ok()).unwrap_or(2500.0); + let mut by_kind: HashMap = HashMap::new(); + let (mut launched, mut kills, mut leaked, mut tickets) = (0, 0, 0, 0); + for seed in 1..=8u64 { + let mut s = Setup::new(50, std::env::var("SMART").is_ok(), seed); + s.hilly = true; + s.mountains = true; + s.dist = dist; + s.disc = (disc > 0.0).then_some(disc); + let r = fly(&s); + launched += r.launched; + kills += r.kills; + leaked += r.leaked; + tickets += r.tickets; + for c in &r.crashed { + by_kind.entry(c.0).or_default().0 += 1; + if std::env::var("ROWS").is_ok() { + let (x, z) = (c.1.x as i32, c.1.z as i32); + eprintln!(" seed {seed} {:?} crashed at {:.0},{:.0},{:.0} ({:.0} from origin, land {}), {:.0} from its aim", c.0, c.1.x, c.1.y, c.1.z, c.1.x.hypot(c.1.z), Loader::new(&s).height(x, z), c.2); + } + } + } + let mut v: Vec<_> = by_kind.into_iter().collect(); + v.sort_by_key(|k| k.0 as u8); + eprintln!("disc {disc} dist {dist}: launched {launched} kills {kills} leaked {leaked} chunk tickets {tickets}; crashed by kind {:?}", v.iter().map(|k| (k.0, k.1.0)).collect::>()); +} diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index f6b4639..55d9956 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -36,7 +36,7 @@ mod regress; mod sensor_tests; pub use ew::EW_RADIUS; pub use damage::{Comps, Part, PartState}; -pub use sight::{Ident, Track}; +pub use sight::{Ident, LowSensor, Track, RIM_RAYS}; use damage::dice; use outcome::{Impact, Outcome}; pub use units::{Family, SamKind}; @@ -356,6 +356,21 @@ impl Ground { return false; } self.chunks.push(c); + // What was answered as "no chunk" there is asked again: the chunk is being loaded now. + for x in c.0 * 16..c.0 * 16 + 16 { + for z in c.1 * 16..c.1 * 16 + 16 { + if self.known.get(&(x, z)).is_some_and(|v| v.0 <= -64) { + self.known.remove(&(x, z)); + } + } + } + for x in (c.0 * 16..c.0 * 16 + 16).step_by(8) { + for z in (c.1 * 16..c.1 * 16 + 16).step_by(8) { + if self.known.get(&(x + 4, z + 4)).is_some_and(|v| v.0 <= -64) { + self.known.remove(&(x + 4, z + 4)); + } + } + } true } /// How many chunks are wanted so far this tick. @@ -920,11 +935,14 @@ struct Nav { need: f64, /// The height from which a straight dive on its target clears the land (unknown: -inf). dive: f64, + /// The land ahead is not known (a chunk the game does not have, or not yet): it keeps a transit height + /// above anything it could meet and comes down only once the land ahead is told of. + blind: bool, } impl Default for Nav { fn default() -> Self { - Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY } + Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY, blind: false } } } @@ -1492,6 +1510,11 @@ pub struct Sim { pub hits: u32, /// Drones that struck the land or a building short of their aim. pub crashes: u32, + /// Systems just deployed by hand and not yet checked for standing in the land: (id, until tick). + settling: Vec<(u32, u64)>, + /// What crashed, where, and its aim (balance measurements only). + #[cfg(test)] + pub crashed: Vec<(DroneKind, DVec3, DVec3)>, pub launched: u32, /// Explosions damage the land (primed TNT through commands). pub damage: bool, @@ -1578,6 +1601,9 @@ impl Sim { kills: 0, hits: 0, crashes: 0, + settling: Vec::new(), + #[cfg(test)] + crashed: Vec::new(), launched: 0, damage: true, seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1, @@ -1707,12 +1733,43 @@ impl Sim { self.fires.extinguish(); } + /// A system just put down by the player is looked at for a few seconds: one standing inside the land (a player + /// who fell into a chunk that had not loaded) is brought up to the surface. Buried, its interceptors would + /// strike the dirt above it and the raid's aim at it would lie under the land. Only a spot whose block is known + /// solid and whose land height is known is moved, to that height. + pub fn settle(&mut self, id: u32) { + self.settling.push((id, self.tick + 100)); + } + + fn step_settling(&mut self, ground: &mut Ground) { + let now = self.tick; + let sams = &mut self.sams; + self.settling.retain(|&(id, until)| { + let Some(sam) = sams.iter_mut().find(|s| s.id == id) else { return false }; + let p = sam.pos; + let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); + if ground.world.is_known_open(x, y, z, now) { + return false; + } + if ground.world.is_known_solid(x, y, z, now) { + if let Some(g) = ground.at(p, now) { + if g > p.y + 1.5 && g < p.y + 96.0 { + sam.pos.y = g; + } + return false; + } + } + now < until + }); + } + /// One game tick. pub fn step(&mut self, ground: &mut Ground, player: Option, out: &mut Out) { self.tick += 1; self.plan_step(Some(&mut *ground), out); self.adapt(); - self.release_queue(out); + self.step_settling(ground); + self.release_queue(ground, out); self.step_drones(ground, player, out); self.step_later(ground, player, out); self.step_moves(ground, out); @@ -1754,11 +1811,12 @@ impl Sim { let from = d.pos; ground.ahead(d.pos, d.vel, (120.0 / d.vel.length().max(0.05)).max(20.0), now); // Near its aim it looks at the land there again: a platform or hilltop blown away - // meanwhile, it goes for what is left below, not for the air where it stood. + // meanwhile, it goes for what is left below, not for the air where it stood; an aim inside the land (a mark + // set from a spot the player stood in the ground) is the surface over it, else the strike counts as a crash. if d.flight != Flight::Falling && horizontal(d.target - d.pos).length() < 250.0 && let Some(g) = ground.at(d.target, now) - && g < d.target.y - 1.5 + && (g < d.target.y - 1.5 || (g > d.target.y + 1.5 && !spec.decoy && spec.ballistic.is_none() && g < d.target.y + 96.0)) { d.target.y = g; } @@ -1820,6 +1878,10 @@ impl Sim { // Into the land or a building short of its aim: a crash, not a hit. if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 { crashes.push((d.id, d.kind, d.pos, spec.warhead)); + #[cfg(test)] + { + self.crashed.push((d.kind, d.pos, d.target)); + } removed.push(d.id); continue; } @@ -1921,7 +1983,9 @@ impl Sim { lines.push(((heading + towards).normalize_or_zero(), reach)); } } - d.nav.need = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb).unwrap_or(f64::NEG_INFINITY); + let (need, blind) = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb); + d.nav.need = need.unwrap_or(f64::NEG_INFINITY); + d.nav.blind = blind; d.nav.dive = if to_target < dive_at * 3.0 { terrain::dive_height(ground, d.pos, d.target, terrain::DIVE_MARGIN).unwrap_or(f64::NEG_INFINITY) } else { @@ -1954,13 +2018,14 @@ impl Sim { } else { let heading = horizontal(way - d.pos).normalize_or(horizontal(old).normalize_or(DVec3::NEG_Z)); // Cruise height over the target; as low as the land lets it when under the radars. - let base = if d.low { d.target.y + 4.0 } else { d.target.y + spec.cruise }; + let base = if d.low && !d.nav.blind { d.target.y + 4.0 } else { d.target.y + spec.cruise }; let own = below.map_or(f64::NEG_INFINITY, |g| g + spec.clearance); let need = d.nav.need.max(own); let safe = if need.is_finite() { need + 2.0 } else { d.target.y + spec.clearance }; let height = base.max(safe).max(d.nav.dive + 1.0); let full = climb.pitch.tan(); - let urgent = need.is_finite() && d.pos.y < need; + // Blind (land ahead unknown): to the transit height at once, not at the gentle slope of a descent plan. + let urgent = (need.is_finite() && d.pos.y < need) || (d.nav.blind && d.pos.y < base - 2.0); let slope = if urgent { full } else { @@ -1997,6 +2062,10 @@ impl Sim { if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 { if !spec.decoy { crashes.push((d.id, d.kind, d.pos, spec.warhead)); + #[cfg(test)] + { + self.crashed.push((d.kind, d.pos, d.target)); + } } removed.push(d.id); continue; diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 26df672..a0766b7 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -237,6 +237,9 @@ impl PartialOrd for Open { } } +/// Ticks an air start waits for the land under it to be told of. +const AIR_START_WAIT: u32 = 120; + impl Sim { /// Orders a raid on `objects` (at least one point). A smart raid is planned over the next ticks /// (each route found in full, a few a tick) and launched when its plan is ready. @@ -649,8 +652,11 @@ impl Sim { (prices, cost) } - /// A planned raid's drones take off when their time comes. - pub(super) fn release_queue(&mut self, out: &mut Out) { + /// Ticks an air start waits for the land under it to be told of. +const AIR_START_WAIT: u32 = 120; + +/// A planned raid's drones take off when their time comes. + pub(super) fn release_queue(&mut self, ground: &mut Ground, out: &mut Out) { let now = self.tick; if self.queue.iter().all(|q| q.0 > now) { return; @@ -658,16 +664,41 @@ impl Sim { let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now); self.queue = wait; let mut cancelled = 0; - for (t, d) in go { + for (t, mut d) in go { // A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it. let from_site = self.sites.site_of(d.id); + // An air start (an off-map site's drone) was planned over land nobody had read. It waits (a second or + // two) for the land under it to be known, and begins above it if that is higher than the start: else it + // is born inside a hill and crashes at once. + let air = from_site.is_some_and(|id| self.sites.list.iter().any(|s| s.id == id && s.offmap)); + if air { + let clear = if d.kind.spec().ballistic.is_some() { 10.0 } else { d.kind.spec().clearance }; + match ground.at(d.pos, now) { + None if d.age < AIR_START_WAIT => { + // The game has no chunk there: it is asked to load the ones round the start (and kept a while). + let c = ((d.pos.x.floor() as i32) >> 4, (d.pos.z.floor() as i32) >> 4); + for (dx, dz) in [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] { + ground.want_chunk((c.0 + dx, c.1 + dz)); + } + d.age += 1; + self.queue.push((t, d)); + continue; + } + Some(g) if d.pos.y < g + clear => d.pos.y = g + clear, + _ => {} + } + d.age = 0; + } let slot = self.site_slot(d.id); if let (Some(site), sites::Slot::Go | sites::Slot::Air) = (from_site, &slot) { self.scout_launch(d.id, site, d.kind, d.pos); } match slot { // Free: not a site's job. Air: an off-map site's drone, already in flight position. - sites::Slot::Free | sites::Slot::Air => {} + sites::Slot::Free => {} + // The air start was planned over land nobody had read; if it is read by now and the land there is + // higher than the start, it begins above it (else it is born inside a hill and crashes at once). + sites::Slot::Air => {} sites::Slot::Busy => { self.queue.push((t, d)); continue; @@ -677,6 +708,14 @@ impl Sim { continue; } sites::Slot::Go => { + // On a slope the pad's side or the muzzle's far end can stand in the land that rises there: the + // drone leaves from above the land at its own column, not from inside the hill. + if let Some(g) = ground.at(d.pos, now) + && d.pos.y < g + 1.5 + && g < d.pos.y + 40.0 + { + d.pos.y = g + 1.5; + } let dir = d.vel.normalize_or_zero(); out.sound(d.kind.launch_sound(), d.pos, 1.0, 1.0); out.particles("minecraft:flame", d.pos - dir * 1.5, 0.15, -dir * 0.1, 0.02, 4); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index e1b8c5c..aeab211 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -1594,3 +1594,78 @@ fn mass_strikes_keep_fires_effects_and_lights_bounded() { assert!(sim.fires.is_empty(), "fires burn for ever"); let _ = commands; } + +/// A system put down inside the land (the player stood in the ground) is brought up to the surface, and its +/// interceptors then leave the launcher; one on the surface or in the open air is left where it is. +#[test] +fn a_system_deployed_inside_the_land_comes_up() { + let land = sites::testland::Land(|_, _| 63); + let mut sim = Sim::new(1); + let mut ground = Ground::default(); + let mut out = Out::default(); + let buried = sim.id(); + sim.sams.push(Sam::new(buried, SamKind::Patriot, DVec3::new(20.0, 56.0, 17.0), 0.0)); + sim.settle(buried); + let open = sim.id(); + sim.sams.push(Sam::new(open, SamKind::S400, DVec3::new(60.0, 63.0, 17.0), 0.0)); + sim.settle(open); + let air = sim.id(); + sim.sams.push(Sam::new(air, SamKind::Tor, DVec3::new(90.0, 70.0, 17.0), 0.0)); + sim.settle(air); + for _ in 0..60 { + sites::testland::step(&mut sim, &mut ground, &land, &mut out); + } + let y = |id| sim.sams.iter().find(|s| s.id == id).unwrap().pos.y; + assert_eq!(y(buried), 63.0, "a buried system comes up to the surface"); + assert_eq!(y(open), 63.0); + assert_eq!(y(air), 70.0, "one in the open air is not moved"); +} + +/// An aim point inside the land (an object marked where the player stood in the ground) is the surface over it: +/// a drone that arrives is a hit, not a crash into the land short of its aim. +#[test] +fn a_drone_at_an_aim_under_the_land_hits() { + let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 63)); + let mut sim = Sim::new(5); + sim.targets.push(DVec3::new(0.0, 56.0, 0.0)); + sim.target_health.push(100.0); + let d = sim.make_drone(DroneKind::Geran3, DVec3::new(-400.0, 100.0, 0.0), DVec3::X, DVec3::new(0.0, 56.0, 0.0)); + sim.drones.push(d); + let mut out = Out::default(); + for _ in 0..1200 { + sites::testland::step(&mut sim, &mut ground, &land, &mut out); + if sim.drones.is_empty() { + break; + } + } + assert_eq!((sim.hits, sim.crashes), (1, 0), "hits {} crashes {}", sim.hits, sim.crashes); +} + +/// Over land the game has not told of, a low-flying drone does not hug the height of its aim: it holds the transit +/// height (the cruise height over the aim) and comes down only once the land ahead is known. +#[test] +fn over_unknown_land_a_low_flight_holds_its_transit_height() { + let aim = DVec3::new(0.0, 64.0, 3000.0); + let fly = |known: bool| { + let mut sim = Sim::new(4); + let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 70.0, 0.0), DVec3::Z, aim); + d.low = true; + d.route = vec![aim]; + sim.drones.push(d); + let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64)); + let mut out = Out::default(); + for _ in 0..300 { + if known { + sites::testland::step(&mut sim, &mut ground, &land, &mut out); + } else { + sim.step(&mut ground, None, &mut out); + } + } + sim.drones[0].pos.y + }; + let spec = DroneKind::Kh101.spec(); + let blind = fly(false); + let seen = fly(true); + assert!(blind > aim.y + spec.cruise - 8.0, "blind and low: {blind}"); + assert!(seen < aim.y + 20.0, "over land it knows it keeps hugging it: {seen}"); +} diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index 9aa3df8..53f16e4 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -93,6 +93,97 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 { radar.min(horizon).max(NEAR) } +/// The low flyer the command map shows the radars' coverage for: a Shahed over the land. +const LOW_KIND: DroneKind = DroneKind::Shahed; + +/// A radar as the command map sees it: where its antenna is and how far it reaches a low flyer. +#[derive(Clone, Copy)] +pub struct LowSensor { + pub id: u32, + pub ant: DVec3, + /// The ground under the unit (a place whose land is unknown is taken as level with it). + pub base: f64, + pub reach: f64, + /// Its horizon is known: the land hides what it hides. + pub terrain: bool, +} + +/// Rays of `Sim::low_rim`. +pub const RIM_RAYS: usize = RAYS; + +impl Ground { + /// The land's height at `p` from what the game has already answered; asks for nothing. + pub fn peek(&self, p: DVec3) -> Option { + const LAT: i32 = super::terrain::LATTICE; + let k = (p.x.floor() as i32, p.z.floor() as i32); + let hit = self.known.get(&k).or_else(|| { + self.known.get(&(k.0.div_euclid(LAT) * LAT + LAT / 2, k.1.div_euclid(LAT) * LAT + LAT / 2)) + }); + hit.and_then(|&(y, _)| (y > -64).then_some(f64::from(y))) + } +} + +impl Sim { + /// A radar for a unit of `kind` at `pos` as the map draws it; `id`: the unit it is (its parts and horizon), None for a ghost. + pub fn low_sensor(&self, kind: SamKind, pos: DVec3, id: Option) -> Option { + let spec = kind.spec(); + if spec.sensor <= 0.0 { + return None; + } + let fit = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).map_or(1.0, |s| s.radar_fit()); + let height = LOW_KIND.spec().clearance; + let radar = spec.sensor * fit * LOW_KIND.rcs().powf(0.25); + let reach = radar.min(HORIZON * (spec.mast.sqrt() + height.sqrt())).max(NEAR); + let terrain = id.is_some_and(|i| self.horizons.contains_key(&i)); + Some(LowSensor { id: id.unwrap_or(u32::MAX), ant: pos + DVec3::Y * spec.mast, base: pos.y, reach, terrain }) + } + + /// The radars that are up now. + pub fn low_sensors(&self) -> Vec { + self.sams.iter().filter(|s| s.radar_up()).filter_map(|s| self.low_sensor(s.kind, s.pos, Some(s.id))).collect() + } + + /// Whether the radar sees a low flyer (over the land) at the column (`x`, `z`): in its reach and not behind the land. + pub fn low_sees(&self, ground: &Ground, s: &LowSensor, x: f64, z: f64) -> bool { + let (dx, dz) = (x - s.ant.x, z - s.ant.z); + let d2 = dx * dx + dz * dz; + if d2 > s.reach * s.reach { + return false; + } + if !s.terrain || d2 <= NEAR * NEAR { + return true; + } + let Some(h) = self.horizons.get(&s.id) else { return true }; + let g = ground.peek(DVec3::new(x, 0.0, z)).unwrap_or(s.base); + !h.hides(s.ant, DVec3::new(x, g + LOW_KIND.spec().clearance, z)) + } + + /// Per ray round the radar, how far a low flyer is seen before the land first hides it (at most the reach). + pub fn low_rim(&self, ground: &Ground, s: &LowSensor) -> Vec { + let mut out = vec![s.reach as f32; RAYS]; + let Some(h) = self.horizons.get(&s.id).filter(|_| s.terrain) else { return out }; + let steps = ((s.reach / STEP) as usize).min(h.rays[0].len()); + let height = LOW_KIND.spec().clearance; + for (r, o) in out.iter_mut().enumerate() { + let a = r as f64 / RAYS as f64 * TAU; + let (sx, sz) = (a.sin(), a.cos()); + for k in 1..=steps { + let d = k as f64 * STEP; + if d <= NEAR { + continue; + } + let p = DVec3::new(s.ant.x + sx * d, 0.0, s.ant.z + sz * d); + let y = ground.peek(p).unwrap_or(s.base) + height - s.ant.y; + if ((y / d) as f32) < h.rays[r][k - 1] { + *o = (d - STEP).max(NEAR) as f32; + break; + } + } + } + out + } +} + /// How a kind's radar looks round. pub(super) struct Scan { /// Ticks for the beam to go round. @@ -367,3 +458,74 @@ impl Sim { best } } + +#[cfg(test)] +mod low_view_tests { + use super::*; + + /// A radar on level land (70) with a hill east of it (140 high, x 128..160): the east ray is cut at the hill, the others are not. + fn world() -> (Sim, Ground, u32) { + let mut sim = Sim::new(3); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0)); + let mut ground = Ground::default(); + let cols: Vec<(i32, i32, i32)> = (-60..60).map(|k| (k * 16, 0, if (128..160).contains(&(k * 16)) { 140 } else { 70 })).collect(); + ground.answer(cols, 0); + let h = Horizon::build(antenna(&sim.sams[0]), sim.sams[0].kind.spec().sensor, &mut ground, 0); + sim.horizons.insert(id, h); + (sim, ground, id) + } + + #[test] + fn the_land_cuts_the_coverage_edge_behind_a_hill() { + let (sim, ground, id) = world(); + let ls = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), Some(id)).unwrap(); + assert!(ls.terrain && ls.reach > 200.0, "{}", ls.reach); + let rim = sim.low_rim(&ground, &ls); + let east = rim[RIM_RAYS / 4]; + assert!((128.0..=176.0).contains(&east), "east edge {east}"); + // Rays whose land is unknown hide nothing: the whole reach. + assert_eq!(rim[0], ls.reach as f32); + assert_eq!(rim[RIM_RAYS * 3 / 4], ls.reach as f32); + assert!(sim.low_sees(&ground, &ls, -200.0, 0.0)); + assert!(sim.low_sees(&ground, &ls, 100.0, 0.0)); + assert!(!sim.low_sees(&ground, &ls, 200.0, 0.0), "behind the hill"); + assert!(!sim.low_sees(&ground, &ls, ls.reach + 50.0, 0.0), "out of reach"); + // A ghost (no horizon) sees the whole circle. + let ghost = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), None).unwrap(); + assert!(sim.low_sees(&ground, &ghost, 200.0, 0.0)); + } + + // The map's tint asks `low_sees` for a cell of a coarse grid at every radar that reaches it, and the edges + // ask `low_rim`: with a Polygon's worth of systems both stay cheap (native time, a few ms; wasm is a few times that). + #[test] + fn coverage_of_128_systems_is_cheap() { + let (mut sim, mut ground, _) = world(); + for i in 0..127 { + let id = sim.id(); + let pos = DVec3::new((i % 16) as f64 * 40.0 - 300.0, 70.0, (i / 16) as f64 * 40.0 - 150.0); + sim.sams.push(Sam::new(id, SamKind::Buk, pos, 0.0)); + let k = sim.sams.len() - 1; + let h = Horizon::build(antenna(&sim.sams[k]), sim.sams[k].kind.spec().sensor, &mut ground, 0); + sim.horizons.insert(id, h); + } + let sensors = sim.low_sensors(); + assert_eq!(sensors.len(), 128); + let t = std::time::Instant::now(); + let mut seen = 0; + for cy in 0..28 { + for cx in 0..36 { + let (x, z) = (cx as f64 * 30.0 - 500.0, cy as f64 * 30.0 - 400.0); + // Worst case for the early exit: a cell no radar sees is tested against all that reach it. + if sensors.iter().any(|s| sim.low_sees(&ground, s, x, z)) { + seen += 1; + } + } + } + let tint = t.elapsed(); + let t = std::time::Instant::now(); + let rims: usize = sensors.iter().take(3).map(|s| sim.low_rim(&ground, s).len()).sum(); + eprintln!("tint of 1008 cells: {tint:?} ({seen} seen); 3 rims: {:?} ({rims})", t.elapsed()); + assert!(seen > 0 && tint.as_millis() < 100, "{tint:?}"); + } +} diff --git a/airdef-mod/src/sim/sites.rs b/airdef-mod/src/sim/sites.rs index ffdd760..8d1de66 100644 --- a/airdef-mod/src/sim/sites.rs +++ b/airdef-mod/src/sim/sites.rs @@ -805,6 +805,25 @@ mod tests { } } + /// An off-map site's drones start in the air at the height planned over land nobody had read; if the land + /// turns out higher there by the time they leave, they start above it instead of being born inside the hill. + #[test] + fn an_off_map_start_over_a_hill_begins_above_it() { + let (mut ground, mut out) = (Ground::default(), Out::default()); + let mut sim = Sim::new(3); + sim.order_raid(&order(Approach::TwoSectors, 300.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out); + while sim.planning() { + sim.step(&mut ground, None, &mut out); + } + assert!(sim.sites.list.iter().all(|s| s.offmap)); + let land = Land(|x, z| if x * x + z * z > 200 * 200 { 150 } else { 64 }); + for _ in 0..500 { + step(&mut sim, &mut ground, &land, &mut out); + assert!(sim.drones.iter().all(|d| d.age > 3 || d.pos.y > 148.0 || d.flight == Flight::Ballistic), "a drone started inside the hill"); + } + assert!(sim.launched > 0); + } + /// Chunks that load on request: a column of a chunk asked for answers `PENDING` until `delay` ticks /// after the first request, and a chunk never asked for is not there (-64). struct Lazy { diff --git a/airdef-mod/src/sim/terrain.rs b/airdef-mod/src/sim/terrain.rs index 6040450..6dab086 100644 --- a/airdef-mod/src/sim/terrain.rs +++ b/airdef-mod/src/sim/terrain.rs @@ -10,7 +10,7 @@ use super::*; /// Land heights are sampled at the centres of cells this wide. -const LATTICE: i32 = 8; +pub(super) const LATTICE: i32 = 8; /// Land questions waiting at once: flights ask for more than a tick can send, and wait. const ASK_MAX: usize = 192; @@ -168,6 +168,9 @@ pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 { horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin() } +/// Chunks a tick the flights' look-ahead may ask the game to load (the rest of the budget is the launch-site search's). +const CHUNK_BUDGET: usize = 46; + /// Distances ahead where the land is sampled. const REACH: [f64; 21] = [ 0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0, @@ -177,8 +180,10 @@ const REACH: [f64; 21] = [ /// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and /// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance /// less the height it can gain on the way. `None` when nothing is known. -pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> Option { +pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> (Option, bool) { let mut need: Option = None; + // Land nobody has told of within the first half of the look-ahead: the flight is blind there. + let mut blind = false; for &(dir, len) in lines { for &s in REACH.iter().chain(&[900.0]) { if s > len { @@ -187,10 +192,20 @@ pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, if let Some(g) = ground.land(pos + dir * s) { let h = g + clear - climb.gain(s); need = Some(need.map_or(h, |n: f64| n.max(h))); + } else { + if s <= len * 0.5 { + blind = true; + } + // Land the game has not told of ahead: its chunk is asked for (it loads in a couple of seconds, a flight + // is that far from it), so the pull-up starts from the first known column and not at the ridge. + if ground.chunks_wanted() < CHUNK_BUDGET { + let p = pos + dir * s; + ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)); + } } } } - need + (need, blind) } /// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the