Compare commits
3 Commits
v0.3.0-rc1
...
v0.3.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
| 557346548a | |||
| 38e515dea9 | |||
| 57a5426e65 |
@@ -157,7 +157,14 @@ API мода `render.set-lights` (до 512, плавно интерполиру
|
||||
- `/airdef import` — перенести в текущий мир комплексы и цели из старой версии мода (до 0.2.x они были общими для всех миров);
|
||||
- `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт;
|
||||
- `/airdef lastchance <on|off>` — «последний шанс»: комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт (по умолчанию вкл; сохраняется);
|
||||
- `/airdef sites` — известные пусковые площадки (ПУ) и ударные ракеты; `/airdef strike <номер ПУ>` — ответный удар по ПУ (#101, «удары по ПУ»). Как это играется:
|
||||
- **Разведка без волшебства.** Площадку находит только работающая РЛС: либо пуск в её досягаемости (низкая цель: радиогоризонт и холмы решают) даёт точечное наблюдение, либо собственный трек вылетевшей цели «указывает назад» лучом в пару градусов, а время полёта с момента пуска (тревоги) на скорости цели даёт дальность (±8% и секунда времени пуска); у баллистической ракеты дуга точнее. Две РЛС, держащие одну цель, дают два наблюдения. Наблюдения сводятся методом наименьших квадратов, ответ всегда с кругом ошибки (90%), он сужается с каждым пуском. Если места не получилось (круг шире 800 блоков или есть только лучи вдоль одной линии), известен только **азимут** (на скопе метка «N?» на краю), удар невозможен. С местом: ромб на скопе (круг ошибки пунктиром) или метка на краю; в панели строка «Известных ПУ…». Далёкие площадки (километры) открываются грубо (сотни блоков) и бить их почти бесполезно, пока не накопятся пуски.
|
||||
- **Удар.** Ударных ракет мало (3 в запасе, максимум 4, новая за 5 минут), дальность до 4500 блоков; стартуют оттуда, где вы стоите, летят 4 блока в тик (2 км за 25 с) в точку *оценки*. На подлёте ракету может сбить ПВО противника: у ПУ её нет (40%), лёгкая (35%, сбивает в 30%) или тяжёлая (25%, 65%), у баллистических батарей тяжёлая чаще; узнаёте только по итогу. Разброс ракеты 14 блоков; решает истинное расстояние до ПУ: до 35 блоков — **уничтожена** (запас сгорел и рвётся, отложенные запуски отменены, огонь и дымящийся остов, взрыв общим кодом), до 90 — **подавлена** на 30 с (пуски отложены, не отменены), дальше — **промах**, воронка рядом, ПУ цела: очередь за попадание в соседний грунт не списывается. Плохо известную ПУ (круг в сотни блоков) бить почти бесполезно.
|
||||
- **Вне карты.** ПУ без земли (нет загруженной карты) тоже находится: азимут и дальность по трекам, но не рисуется. Удар, который попал бы, прекращает её пуски («результат не виден»); промах теряется.
|
||||
- **Цена выбора.** Ракета запаса не вернётся, пока летит, налёт идёт: близкие площадки не остановить, далёкие (крылатые и баллистические за 2–4 км) успеваете, но для этого нужны РЛС с обзором в сторону налёта и риск промаха или ПВО противника. Отчёт налёта называет сорванные пуски: «Налёт сорван ударами по ПУ (№…): не взлетело N из заявленных M».
|
||||
- Запись: удар попадает в журнал повтора (строка и взрыв/огонь на карте), сами ПУ в повторе не рисуются. Запас ракет входит в сохранение и сценарий (`strikes <запас> <тиков до следующей>`). Все числа механики в одной таблице `TUNE` в `src/sim/strike.rs`.
|
||||
- `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
|
||||
- `/airdef doctrine` — доктрины комплексов (#47; в меню N: «Доктрины…»): `/airdef doctrine <номер|all> reserve <K>` — держать K ракет для срочных целей (баллистика или до удара меньше 12 с); `salvo eco|normal|heavy` — сколько перехватчиков на цель (только базовые / как обычно / на один больше); `sector <центр°> <полуширина°>` или `sector off` — сектор ответственности (0° — север); `guard <объект№>[,<№>]` или `guard off` — стрелять только по тому, что идёт на охраняемые объекты (отметка: `/airdef target here`). По умолчанию ограничений нет; последний шанс сильнее доктрины; `/airdef why <номер>` называет, какая настройка остановила или разрешила пуск; сохраняется с миром и сценариями. Карта охраны и пробелов в ней — вместе с картой (#48).
|
||||
- `/airdef deploy <patriot|gepard|pantsir|s400|s500>` — поставить комплекс в 8 блоках перед собой;
|
||||
- `/airdef hold on|off` — запретить или разрешить ПВО огонь, удобно для съёмки ударов;
|
||||
- `/airdef damage on|off` — разрушать ли ландшафт взрывами (через TNT, нужны читы);
|
||||
|
||||
@@ -58,6 +58,8 @@ pub fn tree() -> Tree {
|
||||
]),
|
||||
Node::lit("why").runs().then(Node::arg("номер", Value::int_from(1)).runs()),
|
||||
Node::lit("raid").runs(),
|
||||
Node::lit("sites").runs(),
|
||||
Node::lit("strike").then(Node::arg("номер", Value::int_from(1)).runs()),
|
||||
Node::lit("repair").runs(),
|
||||
Node::lit("import").runs(),
|
||||
toggle("ew"),
|
||||
@@ -87,6 +89,16 @@ pub fn tree() -> Tree {
|
||||
.runs(),
|
||||
),
|
||||
),
|
||||
Node::lit("doctrine").runs().then(Node::arg("номер", Value::word()).with([
|
||||
Node::lit("reserve").then(Node::arg("K", Value::int(0, 64)).runs()),
|
||||
Node::lit("salvo").then(Node::arg("залп", Value::choice(["eco", "normal", "heavy"])).runs()),
|
||||
Node::lit("sector").with([
|
||||
Node::lit("off").runs(),
|
||||
Node::arg("центр°", Value::number(-720.0, 720.0)).then(Node::arg("полуширина°", Value::number(0.0, 180.0)).runs()),
|
||||
]),
|
||||
Node::lit("guard").with([Node::lit("off").runs(), Node::arg("объекты", Value::word()).runs()]),
|
||||
Node::lit("reset").runs(),
|
||||
])),
|
||||
Node::lit("stats").runs(),
|
||||
])
|
||||
}
|
||||
@@ -149,9 +161,9 @@ mod tests {
|
||||
"replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop",
|
||||
"scenario save a", "scenario save a fresh", "scenario load a", "scenario load a force", "scenario list",
|
||||
"scenario delete a", "why", "why 2", "ew on", "ew off", "move 4", "target here", "targets clear",
|
||||
"deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000",
|
||||
"sites", "strike 3", "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000",
|
||||
"wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", "lastchance on", "lastchance off",
|
||||
"roles all default", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
|
||||
"roles all default", "doctrine", "doctrine 1 reserve 4", "doctrine all salvo eco", "doctrine 2 sector 90 45", "doctrine 2 sector off", "doctrine 1 guard 1,2", "doctrine 1 reset", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
|
||||
] {
|
||||
ok(line);
|
||||
}
|
||||
@@ -187,6 +199,9 @@ mod tests {
|
||||
assert_eq!(bad_at("why x"), 4);
|
||||
assert_eq!(bad_at("move"), 4);
|
||||
assert_eq!(bad_at("move 0"), 5);
|
||||
assert_eq!(bad_at("strike"), 6);
|
||||
assert_eq!(bad_at("strike 0"), 7);
|
||||
assert_eq!(bad_at("sites 1"), 6);
|
||||
assert_eq!(bad_at("scenario"), 8);
|
||||
assert_eq!(bad_at("scenario load"), 13);
|
||||
assert_eq!(bad_at("scenario wipe"), 9);
|
||||
@@ -219,10 +234,10 @@ mod tests {
|
||||
fn completion_offers_the_next_word_only() {
|
||||
let top = complete("", &[]);
|
||||
for w in ["menu", "monitor", "grid", "replay", "scenario", "why", "raid", "repair", "import", "ew", "flare", "move",
|
||||
"target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "lastchance", "roles", "stats"] {
|
||||
"target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "lastchance", "roles", "doctrine", "stats", "sites", "strike"] {
|
||||
assert!(top.contains(&w.to_string()), "{w} in {top:?}");
|
||||
}
|
||||
assert_eq!(top.len(), 25);
|
||||
assert_eq!(top.len(), 28);
|
||||
assert_eq!(complete("rep", &[]), ["repair", "replay"]);
|
||||
assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]);
|
||||
assert_eq!(complete("replay film ", &[]), ["all", "best"]);
|
||||
|
||||
@@ -454,6 +454,8 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
|
||||
});
|
||||
add(Shot::new(a, b, &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i));
|
||||
}
|
||||
// A strike on a launch site (#101) is in the event list and on the map, not in the auto-camera's films.
|
||||
EventKind::Strike { .. } => {}
|
||||
}
|
||||
shots
|
||||
}
|
||||
|
||||
@@ -34,8 +34,13 @@ pub struct Layout {
|
||||
pub seed: Option<u64>,
|
||||
pub wave: Option<Vec<String>>,
|
||||
pub far: Option<bool>,
|
||||
pub acoustics: Option<bool>,
|
||||
pub effects: Option<(bool, bool)>,
|
||||
pub roles: Vec<(SamKind, u8)>,
|
||||
/// Fire doctrines by the system's place in the file (#47).
|
||||
pub doctrines: Vec<(usize, sim::doctrine::Doctrine)>,
|
||||
/// Strike missiles in stock and ticks towards the next (#101).
|
||||
pub strikes: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// One line's words, with the line number for the messages.
|
||||
@@ -124,7 +129,9 @@ impl Layout {
|
||||
}
|
||||
"damage" => out.damage = l.flag(1, "значение")?,
|
||||
"lastchance" => out.last_chance = l.flag(1, "значение")?,
|
||||
"strikes" => out.strikes = Some((l.need(1, "запас", 0.0, COUNT)? as u32, l.num(2, "до следующей", 0.0, COUNT)?.map_or(0, |v| v as u32))),
|
||||
"far" => out.far = l.flag(1, "значение")?,
|
||||
"acoustics" => out.acoustics = l.flag(1, "значение")?,
|
||||
"effects" => out.effects = Some((l.flag(1, "шейдер")?.unwrap_or(true), l.flag(2, "HD")?.unwrap_or(false))),
|
||||
"seed" => {
|
||||
let v = l.w.get(1).ok_or_else(|| l.bad("значение", "строка оборвана"))?;
|
||||
@@ -134,6 +141,16 @@ impl Layout {
|
||||
crate::wave::WaveConfig::default().check(&l.w[1..]).map_err(|e| l.bad("налёт", &e))?;
|
||||
out.wave = Some(l.w[1..].iter().map(|s| s.to_string()).collect());
|
||||
}
|
||||
"doctrine" => {
|
||||
// doctrine <system> <salvo> <reserve> <sector centre|-> <half|-> <guards> [x y z]...
|
||||
let at = l.need(1, "комплекс", 0.0, MAX_UNITS as f64)? as usize;
|
||||
let salvo = l.w.get(2).and_then(|v| sim::doctrine::Salvo::from_key(v)).ok_or_else(|| l.bad("залп", "eco, normal или heavy"))?;
|
||||
let reserve = l.need(3, "резерв", 0.0, sim::doctrine::MAX_RESERVE as f64)? as u32;
|
||||
let sector = if l.w.get(4) == Some(&"-") { None } else { Some((l.need(4, "центр сектора", -1.0e6, 1.0e6)?, l.need(5, "полуширина", 0.0, 7.0)?)) };
|
||||
let n = l.need(6, "число объектов", 0.0, 64.0)? as usize;
|
||||
let guards = (0..n).map(|k| l.xyz(7 + 3 * k, "охраняемый объект")).collect::<Result<Vec<_>, _>>()?;
|
||||
out.doctrines.push((at, sim::doctrine::Doctrine { guards, sector, reserve, salvo }));
|
||||
}
|
||||
"roles" => {
|
||||
let kind = l.kind(1)?;
|
||||
out.roles.push((kind, l.need(2, "роли", 0.0, 255.0)? as u8));
|
||||
@@ -196,6 +213,10 @@ mod tests {
|
||||
assert_eq!(l.sams[3].comps.len(), 6);
|
||||
assert_eq!((l.damage, l.far, l.effects, l.seed), (Some(false), Some(true), Some((true, false)), Some(123456789)));
|
||||
assert_eq!(l.roles.len(), 1);
|
||||
// Saves without strikes load as before; with them, the stock is read (and refused when wrong).
|
||||
assert_eq!(l.strikes, None);
|
||||
assert_eq!(Layout::parse("strikes 2 3500\n").unwrap().strikes, Some((2, 3500)));
|
||||
assert!(Layout::parse("strikes -1 0\n").is_err() && Layout::parse("strikes x\n").is_err());
|
||||
// What the build writes now reads back (a wave line from the menu's own text).
|
||||
let w = crate::wave::WaveConfig::default().text();
|
||||
assert!(Layout::parse(&format!("wave {w}\n")).is_ok(), "{w}");
|
||||
|
||||
+235
-1
@@ -16,6 +16,7 @@ mod cinema;
|
||||
mod engines;
|
||||
mod commands;
|
||||
mod layout;
|
||||
mod map;
|
||||
mod film;
|
||||
mod monitor;
|
||||
mod panel;
|
||||
@@ -87,6 +88,8 @@ struct AirDef {
|
||||
session: u64,
|
||||
/// The world drawn beyond the render distance (replays and cameras see far off).
|
||||
far: bool,
|
||||
/// The acoustics asked for, until the game has them (its answer comes a tick later).
|
||||
acoustics: Option<bool>,
|
||||
/// The game's log of block changes (`render.world-log-start`): a replay shows the world as
|
||||
/// it was at its moment, not the holes the raid left (None where the game has no such log).
|
||||
block_log: Option<u32>,
|
||||
@@ -119,6 +122,8 @@ struct AirDef {
|
||||
model_generation: u32,
|
||||
/// The instances handed to the game, kept between ticks so nothing is allocated for them.
|
||||
by_id: Vec<ModelInstanceId>,
|
||||
/// The command map (#48).
|
||||
map: map::MapUi,
|
||||
}
|
||||
|
||||
/// What `/airdef replay export` was asked for.
|
||||
@@ -212,6 +217,9 @@ enum Page {
|
||||
More,
|
||||
/// The last raid's events and its replays.
|
||||
Replays,
|
||||
/// The systems with their doctrines, and one system's doctrine (by id) (#47).
|
||||
Fleet,
|
||||
Doctrine(u32),
|
||||
}
|
||||
|
||||
const MENU_KINDS: u32 = 1;
|
||||
@@ -236,6 +244,15 @@ const BTN_MAP: u32 = 40;
|
||||
const BTN_FULL: u32 = 41;
|
||||
const BTN_REEL: u32 = 42;
|
||||
const BTN_FAR: u32 = 19;
|
||||
const BTN_ACOUSTICS: u32 = 20;
|
||||
const BTN_DOCTRINE: u32 = 21;
|
||||
const FLEET_ROWS: u32 = 4;
|
||||
const BTN_RESERVE: u32 = 50;
|
||||
const BTN_SALVO: u32 = 51;
|
||||
const BTN_SECTOR_DIR: u32 = 52;
|
||||
const BTN_SECTOR_WIDTH: u32 = 53;
|
||||
const BTN_GUARD: u32 = 54;
|
||||
const BTN_DOC_RESET: u32 = 55;
|
||||
const BTN_CLASS: u32 = 100;
|
||||
const BTN_ALL: u32 = 120;
|
||||
const BTN_DEFAULT: u32 = 121;
|
||||
@@ -506,6 +523,11 @@ impl AirDef {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the game's acoustics are on, or asked to be.
|
||||
fn acoustics_on(&self) -> bool {
|
||||
self.acoustics.unwrap_or_else(render::acoustics)
|
||||
}
|
||||
|
||||
/// The effects preset: none, the post pass, or the post pass under the HD lighting.
|
||||
fn set_effects(&mut self, shader: bool, hd: bool) {
|
||||
self.sim.shader = shader;
|
||||
@@ -861,13 +883,21 @@ impl AirDef {
|
||||
}
|
||||
text += &format!("damage {}\n", self.sim.damage);
|
||||
text += &format!("lastchance {}\n", self.sim.last_chance);
|
||||
// A scenario to replay from the start begins with the full stock.
|
||||
let (stock, progress) = if fresh { (sim::strike::TUNE.stock, 0) } else { (self.sim.strikes.stock, self.sim.strikes.progress) };
|
||||
text += &format!("strikes {stock} {progress}\n");
|
||||
if view {
|
||||
text += &format!("far {}\n", self.far);
|
||||
text += &format!("acoustics {}\n", self.acoustics_on());
|
||||
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
|
||||
}
|
||||
for (kind, roles) in &self.sim.roles {
|
||||
text += &format!("roles {} {}\n", kind.key(), roles);
|
||||
}
|
||||
// Doctrines by the system's place in this file; the default ones aren't written.
|
||||
for (i, s) in self.sim.sams.iter().enumerate().filter(|(_, s)| !s.doctrine.is_default()) {
|
||||
text += &format!("doctrine {i} {}\n", s.doctrine.save_text());
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
@@ -912,6 +942,7 @@ impl AirDef {
|
||||
|
||||
/// Adds a checked layout to the scene: nothing here can fail.
|
||||
fn commit_layout(&mut self, layout: layout::Layout) {
|
||||
let base = self.sim.sams.len();
|
||||
for r in layout.sams {
|
||||
let id = self.sim.id();
|
||||
let mut sam = Sam::new(id, r.kind, r.pos, r.heading);
|
||||
@@ -952,6 +983,9 @@ impl AirDef {
|
||||
if let Some(l) = layout.last_chance {
|
||||
self.sim.last_chance = l;
|
||||
}
|
||||
if let Some((stock, progress)) = layout.strikes {
|
||||
self.sim.strikes.restore(stock, progress);
|
||||
}
|
||||
// A scenario's: the random generator, and the wave menu's raid.
|
||||
if let Some(v) = layout.seed {
|
||||
self.sim.reseed(v);
|
||||
@@ -960,6 +994,10 @@ impl AirDef {
|
||||
let w: Vec<&str> = w.iter().map(String::as_str).collect();
|
||||
self.wave.parse(&w);
|
||||
}
|
||||
if let Some(a) = layout.acoustics {
|
||||
self.acoustics = Some(a);
|
||||
render::set_acoustics(a);
|
||||
}
|
||||
if let Some(f) = layout.far {
|
||||
self.far = f;
|
||||
}
|
||||
@@ -969,6 +1007,11 @@ impl AirDef {
|
||||
for (kind, r) in layout.roles {
|
||||
self.sim.roles.insert(kind, r);
|
||||
}
|
||||
for (i, d) in layout.doctrines {
|
||||
if let Some(s) = self.sim.sams.get_mut(base + i) {
|
||||
s.doctrine = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn give(&self) {
|
||||
@@ -1020,6 +1063,7 @@ impl AirDef {
|
||||
self.sim.shader = shader;
|
||||
self.ground = Ground::default();
|
||||
self.monitor = None;
|
||||
self.map_close();
|
||||
self.grid = false;
|
||||
self.grid_ids.clear();
|
||||
// The last world's transient state: its sounds were due on its own
|
||||
@@ -1363,6 +1407,8 @@ impl AirDef {
|
||||
widgets.push(Widget::List(ListWidget { id: MENU_KINDS, rows, selected: None }));
|
||||
let _ = &self.monitor;
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_GRID, label: "Командный пункт: все комплексы (J)".into() }));
|
||||
let custom = self.sim.sams.iter().filter(|s| !s.doctrine.is_default()).count();
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOCTRINE, label: format!("Доктрины: охрана, сектор, резерв (особых: {custom})…") }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_WAVE, label: "Налёт: состав и запуск…".into() }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPLAYS, label: "Повторы…".into() }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_MORE, label: "Огонь, ремонт, эффекты…".into() }));
|
||||
@@ -1401,6 +1447,7 @@ impl AirDef {
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {fx}") }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_DAMAGE, label: format!("Разрушения: {}", onoff(self.sim.damage)) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_FAR, label: format!("Дальний ландшафт: {}", onoff(self.far)) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_ACOUSTICS, label: format!("Акустика (преграды, эхо): {}", onoff(self.acoustics_on())) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
|
||||
}
|
||||
Page::Wave => {
|
||||
@@ -1425,6 +1472,37 @@ impl AirDef {
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_LAUNCH, label: format!("▶ ПУСК ({})", w.total()) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Состав".into() }));
|
||||
}
|
||||
Page::Fleet => {
|
||||
title = "Доктрины — клик по комплексу".to_string();
|
||||
let rows = self
|
||||
.fleet()
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let s = &self.sim.sams[i];
|
||||
format!("№{} {} · {}", i + 1, s.kind.name(), s.doctrine.text(&self.sim.targets))
|
||||
})
|
||||
.collect();
|
||||
widgets.push(Widget::List(ListWidget { id: FLEET_ROWS, rows, selected: None }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
|
||||
}
|
||||
Page::Doctrine(id) => {
|
||||
let Some(i) = self.sim.sams.iter().position(|s| s.id == id) else { return self.open_menu(Page::Fleet) };
|
||||
let s = &self.sim.sams[i];
|
||||
let d = &s.doctrine;
|
||||
title = format!("№{} {} — доктрина (объяснение: /airdef why {})", i + 1, s.kind.name(), i + 1);
|
||||
let guard = match d.guards.len() {
|
||||
0 => "нет (все цели своих классов)".to_string(),
|
||||
1 => sim::doctrine::object_name(&self.sim.targets, d.guards[0]),
|
||||
n => format!("{n} объектов (клик — снять)"),
|
||||
};
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_GUARD, label: format!("Охраняемый объект: {guard}") }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SECTOR_DIR, label: format!("Сектор: {}", d.sector_text()) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SECTOR_WIDTH, label: format!("Ширина сектора: ±{:.0}°", d.sector.map_or(45.0, |(_, h)| h.to_degrees())) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_RESERVE, label: format!("Резерв ракет: {} (в магазине {})", d.reserve, s.missiles) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SALVO, label: format!("Залп по цели: {}", d.salvo.name()) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOC_RESET, label: "Сбросить доктрину".into() }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Комплексы".into() }));
|
||||
}
|
||||
Page::Roles(kind) => {
|
||||
title = format!("{} ({}) — по каким целям стрелять", kind.name(), panel::distance(kind.range()));
|
||||
let _ = ItemWidget { id: 0, item: String::new(), name: String::new() };
|
||||
@@ -1443,6 +1521,75 @@ impl AirDef {
|
||||
ui::open_screen(&Screen { title, widgets });
|
||||
}
|
||||
|
||||
/// The systems that have a doctrine to set: those with missiles (indices into the sim's list).
|
||||
fn fleet(&self) -> Vec<usize> {
|
||||
(0..self.sim.sams.len()).filter(|&i| self.sim.sams[i].kind.armed()).collect()
|
||||
}
|
||||
|
||||
/// Changes one system's doctrine (the menu's buttons and `/airdef doctrine`).
|
||||
fn edit_doctrine(&mut self, i: usize, f: impl FnOnce(&mut sim::doctrine::Doctrine)) {
|
||||
if let Some(s) = self.sim.sams.get_mut(i) {
|
||||
f(&mut s.doctrine);
|
||||
// It chooses again under the new rules.
|
||||
s.target = None;
|
||||
s.gun_target = None;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `/airdef doctrine [<n|all> reserve K | salvo eco|normal|heavy | sector C H | sector off | guard N[,M]|off | reset]` (#47).
|
||||
fn doctrine_command(&mut self, w: &[&str]) -> Result<String, String> {
|
||||
const USAGE: &str = "/airdef doctrine <номер|all> reserve <K> | salvo eco|normal|heavy | sector <центр°> <полуширина°> | sector off | guard <объект№>[,<№>] | guard off | reset";
|
||||
let Some((who, what)) = w.split_first() else {
|
||||
for i in self.fleet() {
|
||||
let s = &self.sim.sams[i];
|
||||
say(&format!("№{} {}: {}", i + 1, s.kind.name(), s.doctrine.text(&self.sim.targets)));
|
||||
}
|
||||
return Ok(format!("Изменить: {USAGE}"));
|
||||
};
|
||||
let which: Vec<usize> = if *who == "all" {
|
||||
self.fleet()
|
||||
} else {
|
||||
let i = who.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).filter(|&i| self.sim.sams.get(i).is_some_and(|s| s.kind.armed())).ok_or("Нет такого вооружённого комплекса")?;
|
||||
vec![i]
|
||||
};
|
||||
let num = |v: Option<&&str>, what: &str, lo: f64, hi: f64| -> Result<f64, String> {
|
||||
v.and_then(|v| v.parse::<f64>().ok()).filter(|x| x.is_finite() && (lo..=hi).contains(x)).ok_or(format!("{what}: число от {lo} до {hi}"))
|
||||
};
|
||||
let mut edit: Box<dyn Fn(&mut sim::doctrine::Doctrine)> = match what {
|
||||
["reserve", k] => {
|
||||
let k = num(Some(k), "резерв", 0.0, sim::doctrine::MAX_RESERVE as f64)? as u32;
|
||||
Box::new(move |d| d.reserve = k)
|
||||
}
|
||||
["salvo", v] => {
|
||||
let v = sim::doctrine::Salvo::from_key(v).ok_or("Залп: eco, normal или heavy")?;
|
||||
Box::new(move |d| d.salvo = v)
|
||||
}
|
||||
["sector", "off"] => Box::new(|d| d.sector = None),
|
||||
["sector", c, h] => {
|
||||
let c = num(Some(c), "центр сектора", -720.0, 720.0)?.to_radians();
|
||||
let h = num(Some(h), "полуширина сектора", 0.0, 180.0)?.to_radians();
|
||||
Box::new(move |d| d.sector = Some((c, h)))
|
||||
}
|
||||
["guard", "off"] => Box::new(|d| d.guards.clear()),
|
||||
["guard", list] => {
|
||||
let mut at = Vec::new();
|
||||
for n in list.split(',') {
|
||||
let k = n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).and_then(|k| self.sim.targets.get(k)).ok_or(format!("Нет объекта «{n}» (отметьте его /airdef target here)"))?;
|
||||
at.push(*k);
|
||||
}
|
||||
Box::new(move |d| d.guards = at.clone())
|
||||
}
|
||||
["reset"] => Box::new(|d| *d = sim::doctrine::Doctrine::default()),
|
||||
_ => return Err(USAGE.into()),
|
||||
};
|
||||
for &i in &which {
|
||||
self.edit_doctrine(i, &mut edit);
|
||||
}
|
||||
let first = which.first().map(|&i| self.sim.sams[i].doctrine.text(&self.sim.targets)).unwrap_or_default();
|
||||
Ok(format!("Доктрина ({} компл.): {first}", which.len()))
|
||||
}
|
||||
|
||||
fn set_roles(&mut self, kind: SamKind, mask: Option<u8>) {
|
||||
let mask = match mask {
|
||||
Some(m) => {
|
||||
@@ -1490,6 +1637,7 @@ impl Mod for AirDef {
|
||||
session: 0,
|
||||
smoke: Default::default(),
|
||||
far: true,
|
||||
acoustics: None,
|
||||
moving: None,
|
||||
hd: false,
|
||||
world: None,
|
||||
@@ -1504,7 +1652,9 @@ impl Mod for AirDef {
|
||||
model_ids: std::collections::HashMap::new(),
|
||||
model_generation: u32::MAX,
|
||||
by_id: Vec::new(),
|
||||
map: map::MapUi::default(),
|
||||
};
|
||||
let _ = input::register_key("map", "Карта: командная, расстановка", "key.keyboard.m");
|
||||
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
|
||||
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
|
||||
let _ = input::register_key("grid", "Командный пункт: все комплексы", "key.keyboard.j");
|
||||
@@ -1539,6 +1689,7 @@ impl Mod for AirDef {
|
||||
for event in input::poll() {
|
||||
match event {
|
||||
Event::UseStart(u) => self.use_item(&u.item.id),
|
||||
Event::KeyDown(k) if k == "map" => self.map_toggle(),
|
||||
Event::KeyDown(k) if k == "menu" => {
|
||||
if self.replay.is_some() {
|
||||
self.stop_replay();
|
||||
@@ -1662,6 +1813,9 @@ impl Mod for AirDef {
|
||||
}
|
||||
}
|
||||
self.emit(out);
|
||||
self.map.sample(&self.sim);
|
||||
// The map's events and shapes: at the tick's pace (a frame hook's budget is too small for it).
|
||||
self.map_frame();
|
||||
self.draw_hud(DVec3::new(p.x, p.y, p.z), p.yaw);
|
||||
// The replay takes the screen; else the monitors and the cameras they show.
|
||||
if let Some(r) = &self.replay {
|
||||
@@ -1734,6 +1888,58 @@ impl Mod for AirDef {
|
||||
}
|
||||
return;
|
||||
}
|
||||
(Page::Main, BTN_DOCTRINE) => {
|
||||
self.open_menu(Page::Fleet);
|
||||
return;
|
||||
}
|
||||
(Page::Fleet, FLEET_ROWS) => {
|
||||
if let Some(&i) = self.fleet().get(row as usize) {
|
||||
self.open_menu(Page::Doctrine(self.sim.sams[i].id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
(Page::Fleet, BTN_BACK) => {
|
||||
self.open_menu(Page::Main);
|
||||
return;
|
||||
}
|
||||
(Page::Doctrine(_), BTN_BACK) => {
|
||||
self.open_menu(Page::Fleet);
|
||||
return;
|
||||
}
|
||||
(Page::Doctrine(id), b) => {
|
||||
let Some(i) = self.sim.sams.iter().position(|s| s.id == id) else { return };
|
||||
let objects = self.sim.targets.clone();
|
||||
match b {
|
||||
BTN_RESERVE => self.edit_doctrine(i, |d| d.reserve = [0, 1, 2, 4, 8, 16].into_iter().find(|&r| r > d.reserve).unwrap_or(0)),
|
||||
BTN_SALVO => self.edit_doctrine(i, |d| d.salvo = d.salvo.next()),
|
||||
// Eight directions of 45 degrees, then off.
|
||||
BTN_SECTOR_DIR => self.edit_doctrine(i, |d| {
|
||||
let half = d.sector.map_or(45f64.to_radians(), |(_, h)| h);
|
||||
d.sector = match d.sector {
|
||||
None => Some((0.0, half)),
|
||||
Some((c, _)) if (c.to_degrees().rem_euclid(360.0) / 45.0).round() < 7.0 => Some((c + 45f64.to_radians(), half)),
|
||||
Some(_) => None,
|
||||
};
|
||||
}),
|
||||
BTN_SECTOR_WIDTH => self.edit_doctrine(i, |d| {
|
||||
if let Some((c, h)) = d.sector {
|
||||
let deg = [30.0, 45.0, 60.0, 90.0, 135.0].into_iter().find(|&w| w > h.to_degrees() + 0.5).unwrap_or(30.0);
|
||||
d.sector = Some((c, f64::to_radians(deg)));
|
||||
}
|
||||
}),
|
||||
// None, then each object in turn, then none.
|
||||
BTN_GUARD => self.edit_doctrine(i, |d| {
|
||||
let at = d.guards.first().and_then(|g| objects.iter().position(|t| t.distance(*g) < 16.0));
|
||||
d.guards = match (d.guards.len(), at) {
|
||||
(0, _) => objects.first().copied().into_iter().collect(),
|
||||
(1, Some(k)) => objects.get(k + 1).copied().into_iter().collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
}),
|
||||
BTN_DOC_RESET => self.edit_doctrine(i, |d| *d = sim::doctrine::Doctrine::default()),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
(Page::More, BTN_HOLD) => self.sim.hold = !self.sim.hold,
|
||||
(Page::Main, BTN_MONITOR) => self.cycle_monitor(),
|
||||
(Page::Main, BTN_WAVE) => {
|
||||
@@ -1840,6 +2046,12 @@ impl Mod for AirDef {
|
||||
self.apply_far();
|
||||
self.dirty = true;
|
||||
}
|
||||
(Page::More, BTN_ACOUSTICS) => {
|
||||
let on = !self.acoustics_on();
|
||||
self.acoustics = Some(on);
|
||||
render::set_acoustics(on);
|
||||
self.dirty = true;
|
||||
}
|
||||
(Page::More, BTN_DAMAGE) => {
|
||||
self.sim.damage = !self.sim.damage;
|
||||
self.dirty = true;
|
||||
@@ -1898,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()))
|
||||
}
|
||||
@@ -1950,6 +2164,20 @@ impl Mod for AirDef {
|
||||
self.launch_wave(&mut out)?;
|
||||
None
|
||||
}
|
||||
["sites"] => {
|
||||
// The launch sites the radars have found, and the strike missiles (#101).
|
||||
let p = player().ok_or("Нет игрока")?;
|
||||
for l in self.sim.site_lines(DVec3::new(p.x, p.y, p.z)) {
|
||||
say(&l);
|
||||
}
|
||||
None
|
||||
}
|
||||
["strike", n] => {
|
||||
// A strike missile at known site n, from where the player stands.
|
||||
let p = player().ok_or("Нет игрока")?;
|
||||
let id = n.parse::<u32>().map_err(|_| "/airdef strike <номер ПУ из /airdef sites>")?;
|
||||
Some(self.sim.order_strike(id, DVec3::new(p.x, p.y, p.z), &mut out)?)
|
||||
}
|
||||
["move", n] => {
|
||||
// Unit n (as the summary numbers them) drives to where the player stands.
|
||||
let p = player().ok_or("Нет игрока")?;
|
||||
@@ -2007,6 +2235,7 @@ impl Mod for AirDef {
|
||||
self.dirty = true;
|
||||
Some(format!("Разрушения: {}", if self.sim.damage { "вкл" } else { "выкл" }))
|
||||
}
|
||||
["doctrine", rest @ ..] => Some(self.doctrine_command(rest)?),
|
||||
["roles"] => {
|
||||
for kind in SamKind::ARMED {
|
||||
let r = self.sim.roles.get(&kind).copied().unwrap_or(kind.spec().roles);
|
||||
@@ -2061,13 +2290,18 @@ impl Mod for AirDef {
|
||||
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
|
||||
}
|
||||
["stats"] => Some(format!(
|
||||
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
|
||||
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; удары по ПУ: пущено {}, уничтожено {}, подавлено {}, промахов {}, сбито ПВО {}; {}",
|
||||
self.sim.drones.iter().filter(|d| d.alive()).count(),
|
||||
self.sim.missiles.len(),
|
||||
self.sim.sams.len(),
|
||||
self.sim.kills,
|
||||
self.sim.hits,
|
||||
self.sim.crashes,
|
||||
self.sim.strikes.fired,
|
||||
self.sim.strikes.destroyed,
|
||||
self.sim.strikes.stunned,
|
||||
self.sim.strikes.missed,
|
||||
self.sim.strikes.shot_down,
|
||||
self.ground.world.text()
|
||||
)),
|
||||
_ => return Err(help()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,16 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
if hidden > 0 {
|
||||
lines.push(line(format!("Вне видимости РЛС: {hidden}"), GREY));
|
||||
}
|
||||
// The launch sites found, and the strike missiles (#101).
|
||||
if !sim.strikes.known.is_empty() || !sim.strikes.flights.is_empty() {
|
||||
let st = &sim.strikes;
|
||||
let live = st.known.iter().filter(|k| !matches!(k.state, crate::sim::strike::SiteState::Destroyed(_))).count();
|
||||
let mut text = format!("Известных ПУ: {live} · ударных ракет: {}", st.stock);
|
||||
if let Some(f) = st.flights.iter().map(|f| f.arrive.saturating_sub(sim.tick)).min() {
|
||||
text += &format!(" · в полёте {} (до цели {} с)", st.flights.len(), f / 20);
|
||||
}
|
||||
lines.push(line(text, YELLOW));
|
||||
}
|
||||
if !sim.target_health.is_empty() {
|
||||
let list: Vec<String> = sim.target_health.iter().map(|h| format!("{h:.0}%")).collect();
|
||||
let worst = sim.target_health.iter().copied().fold(100.0, f64::min);
|
||||
|
||||
@@ -107,6 +107,44 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
|
||||
s.push(square(x, y, 1.0, 0xFFFFE060));
|
||||
}
|
||||
}
|
||||
// Launch sites the radars found: a diamond where the place is known (with the circle of its error), and a mark on
|
||||
// the rim along the bearing when it is beyond the scope or only the bearing is known.
|
||||
for k in &sim.strikes.known {
|
||||
let Some(e) = &k.est else { continue };
|
||||
let gone = matches!(k.state, crate::sim::strike::SiteState::Destroyed(_));
|
||||
let color = if gone { 0xFF707070 } else { 0xFFFFA040 };
|
||||
let inside = e.fix.then(|| scope(DVec3::new(e.pos.x, player.y, e.pos.y))).flatten();
|
||||
if let Some((x, y, _)) = inside {
|
||||
let q = 3.0;
|
||||
s.push(quad(pt(x, y - q), pt(x + q, y), pt(x, y + q), pt(x - q, y), color));
|
||||
let rad = (e.radius / RANGE) as f32 * r;
|
||||
if rad > 3.5 {
|
||||
for i in 0..SEGMENTS {
|
||||
let (a0, a1) = (i as f32 / SEGMENTS as f32 * tau, (i + 1) as f32 / SEGMENTS as f32 * tau);
|
||||
if i % 2 == 0 {
|
||||
let p = |a: f32, rr: f32| pt(x + a.sin() * rr, y - a.cos() * rr);
|
||||
s.push(quad(p(a0, rad - 0.4), p(a0, rad + 0.4), p(a1, rad + 0.4), p(a1, rad - 0.4), alpha(color, 0.5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.push(text(k.site.to_string(), x + 5.0, y - 9.0, color, false));
|
||||
} else {
|
||||
// On the rim, along the bearing (compass: north up, east right).
|
||||
let a = e.bearing as f32;
|
||||
let (x, y) = (cx + a.sin() * (r - 3.0), cy - a.cos() * (r - 3.0));
|
||||
let q = if e.fix { 3.0 } else { 2.0 };
|
||||
s.push(quad(pt(x, y - q), pt(x + q, y), pt(x, y + q), pt(x - q, y), color));
|
||||
s.push(text(format!("{}{}", k.site, if e.fix { "" } else { "?" }), x - a.sin() * 8.0, y + a.cos() * -8.0 - 4.0, color, true));
|
||||
}
|
||||
}
|
||||
// Strike missiles in flight, on the same footing: a small bright dot where the scope reaches, else nothing.
|
||||
for f in &sim.strikes.flights {
|
||||
let t = (sim.tick.saturating_sub(f.start)) as f64 / (f.arrive - f.start).max(1) as f64;
|
||||
let p = f.from.lerp(DVec3::new(f.aim.x, f.from.y, f.aim.y), t.min(1.0));
|
||||
if let Some((x, y, _)) = scope(p) {
|
||||
s.push(square(x, y, 1.5, 0xFFFFFFA0));
|
||||
}
|
||||
}
|
||||
let mut far = 0;
|
||||
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
|
||||
// is a forecast, dimmer); with no system deployed, everything as it is.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use crate::film::{self, Film};
|
||||
use crate::panel::distance;
|
||||
use crate::sim::Ground;
|
||||
use crate::sim::record::{EventKind, Recording, STEP, What};
|
||||
use crate::sim::record::{EventKind, Recording, STEP, StrikeResult, What};
|
||||
use std::cell::Cell;
|
||||
use crate::sim::{Class, DroneKind, Out, Sim};
|
||||
use glam::DVec3;
|
||||
@@ -137,6 +137,17 @@ fn outcome_note(outcome: crate::sim::outcome::Outcome) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a strike on a launch site ended, in words.
|
||||
fn strike_word(result: StrikeResult) -> &'static str {
|
||||
match result {
|
||||
StrikeResult::Destroyed => "площадка уничтожена",
|
||||
StrikeResult::Stunned => "площадка подавлена",
|
||||
StrikeResult::Missed => "промах",
|
||||
StrikeResult::Intercepted => "ракета сбита ПВО противника",
|
||||
StrikeResult::Halted => "пуски прекращены (вне карты)",
|
||||
}
|
||||
}
|
||||
|
||||
/// What an event was, in a line.
|
||||
pub fn title(rec: &Recording, i: usize) -> String {
|
||||
let e = &rec.events[i];
|
||||
@@ -151,6 +162,7 @@ pub fn title(rec: &Recording, i: usize) -> String {
|
||||
EventKind::SamLost { kind, index, .. } => format!("{} №{} уничтожен", kind.name(), index + 1),
|
||||
EventKind::Split { .. } => "«Орешник»: разделение боевых блоков".into(),
|
||||
EventKind::Crash { kind, .. } => format!("«{}» разбивается о препятствие", kind.spec().name),
|
||||
EventKind::Strike { site, result } => format!("Удар по ПУ №{site}: {}", strike_word(result)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +179,7 @@ pub fn row(rec: &Recording, i: usize) -> String {
|
||||
EventKind::SamLost { kind, index, .. } => format!("{}-{} уничтожен", kind.name(), index + 1),
|
||||
EventKind::Split { .. } => "Орешник: разделение".into(),
|
||||
EventKind::Crash { kind, .. } => format!("{}: о рельеф", kind.spec().name),
|
||||
EventKind::Strike { site, result } => format!("ПУ {site}: {}", strike_word(result)),
|
||||
};
|
||||
format!("{} {what}", clock(e.at as f64))
|
||||
}
|
||||
@@ -199,6 +212,7 @@ pub fn interest(rec: &Recording, i: usize) -> f64 {
|
||||
}
|
||||
EventKind::SamLost { .. } => 12.0,
|
||||
EventKind::Crash { power, .. } => power as f64,
|
||||
EventKind::Strike { .. } => 0.0,
|
||||
EventKind::Split { drone } => {
|
||||
// The bus's split, not each block's.
|
||||
let first = rec.events.iter().position(|o| matches!(o.kind, EventKind::Split { .. }));
|
||||
@@ -216,6 +230,7 @@ fn clip(rec: &Recording, i: usize) -> Clip {
|
||||
EventKind::SamLost { .. } => (80.0, 90.0),
|
||||
EventKind::Split { .. } => (40.0, 170.0),
|
||||
EventKind::Crash { .. } => (120.0, 60.0),
|
||||
EventKind::Strike { .. } => (60.0, 80.0),
|
||||
};
|
||||
Clip { event: i, from: (at - before).max(0.0), to: (at + after).min(rec.len as f64) }
|
||||
}
|
||||
@@ -499,6 +514,12 @@ impl Player {
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
|
||||
EventKind::SamLost { .. } => "airdef:blast.big",
|
||||
EventKind::Split { .. } => "airdef:boom.sonic",
|
||||
EventKind::Strike { result: StrikeResult::Halted, .. } => continue,
|
||||
EventKind::Strike { result, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][match result {
|
||||
StrikeResult::Destroyed => 4,
|
||||
StrikeResult::Intercepted => 0,
|
||||
_ => 3,
|
||||
}],
|
||||
};
|
||||
let gain = if let EventKind::Kill { kind, outcome, .. } = e.kind { crate::sim::outcome::look(kind, outcome).2 } else { 1.0 };
|
||||
out.sound(event, heard(e.pos), gain, 1.0);
|
||||
@@ -694,7 +715,7 @@ impl Player {
|
||||
let look = rec.track(drone).and_then(|tr| tr.at(o, t)).filter(|_| t < te).unwrap_or(e.pos);
|
||||
(eye, look, 50.0, 1)
|
||||
}
|
||||
EventKind::SamLost { .. } => (side(e.pos, o + DVec3::new(300.0, 0.0, 0.0), 45.0), e.pos, 50.0, 0),
|
||||
EventKind::SamLost { .. } | EventKind::Strike { .. } => (side(e.pos, o + DVec3::new(300.0, 0.0, 0.0), 45.0), e.pos, 50.0, 0),
|
||||
EventKind::Split { .. } => {
|
||||
let ground = DVec3::new(e.pos.x, o.y, e.pos.z);
|
||||
let eye = ground + DVec3::new(170.0, 8.0, 40.0);
|
||||
@@ -953,6 +974,7 @@ impl Player {
|
||||
}
|
||||
EventKind::SamLost { .. } => s.push(fill(x - 4.0, y - 4.0, 8.0, 8.0, 0xFF200000)),
|
||||
EventKind::Split { .. } => s.push(fill(x - 2.0, y - 2.0, 4.0, 4.0, 0xFFFF9020)),
|
||||
EventKind::Strike { .. } => s.push(fill(x - 3.0, y - 3.0, 6.0, 6.0, 0xFFFFA040)),
|
||||
}
|
||||
}
|
||||
// Header, legend, time line.
|
||||
@@ -983,6 +1005,7 @@ impl Player {
|
||||
EventKind::Crash { .. } => 0xFF903020,
|
||||
EventKind::SamLost { .. } => 0xFFFF00FF,
|
||||
EventKind::Split { .. } => 0xFFFF9020,
|
||||
EventKind::Strike { .. } => 0xFFFFA040,
|
||||
};
|
||||
s.push(fill(x, ty - 2.0, 1.0, 7.0, c));
|
||||
}
|
||||
|
||||
+296
-14
@@ -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,20 @@ 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 scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
|
||||
}
|
||||
|
||||
pub(super) struct Setup {
|
||||
@@ -65,20 +72,137 @@ 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<f64>,
|
||||
/// 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,
|
||||
/// How steep the hills are (1 is the usual), and whether a forest's canopy stands on them (the game's height map shows the leaves).
|
||||
pub steep: f64,
|
||||
pub trees: bool,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
hills_k(x, z, 1.0)
|
||||
}
|
||||
|
||||
/// `hills` with its ridges `k` times as steep (higher and closer together).
|
||||
pub(super) fn hills_k(x: i32, z: i32, k: f64) -> i32 {
|
||||
let (x, z) = (f64::from(x) * k, f64::from(z) * k);
|
||||
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 * k + 14.0).max(-4.0) as i32
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
steep: f64,
|
||||
trees: bool,
|
||||
asked: std::cell::RefCell<HashMap<(i32, i32), (u64, u64)>>,
|
||||
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, steep: s.steep, trees: s.trees, asked: Default::default(), tickets: 0 }
|
||||
}
|
||||
/// The land without the forest on it.
|
||||
pub fn bare(&self, x: i32, z: i32) -> f64 {
|
||||
f64::from(hills_k(x, z, self.steep) + if self.mountains { mountains(x, z) } else { 0 })
|
||||
}
|
||||
pub fn height(&self, x: i32, z: i32) -> i32 {
|
||||
if !self.hilly {
|
||||
return 64;
|
||||
}
|
||||
// A forest: a quarter of the 3x3 patches carry a canopy 8 blocks over the ground (the game's height map shows the leaves).
|
||||
let canopy = if self.trees && ((x.div_euclid(3).wrapping_mul(73856093) ^ z.div_euclid(3).wrapping_mul(19349663)) as u32 >> 3) % 4 == 0 { 8 } else { 0 };
|
||||
hills_k(x, z, self.steep) + canopy + if self.mountains { mountains(x, z) } else { 0 }
|
||||
}
|
||||
fn loaded(&self, c: (i32, i32), now: u64) -> Option<bool> {
|
||||
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<bool> {
|
||||
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, steep: std::env::var("STEEP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0), trees: std::env::var("TREES").is_ok() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 l = Loader::new(s);
|
||||
let y = |x: f64, z: f64| if s.hilly { l.bare(x as i32, z as i32) - 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 +212,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 +230,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 +251,7 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
let start = sim.tick;
|
||||
let mut gone_unkilled: Vec<Hist> = 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 +310,9 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.tickets = land.tickets;
|
||||
result.scrubs = sim.scrubs.clone();
|
||||
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 +320,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<DroneKind, (u32, u32)> = HashMap::new();
|
||||
for h in &gone_unkilled {
|
||||
@@ -215,33 +346,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::<Vec<_>>()); }
|
||||
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::<Vec<_>>()); }
|
||||
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 +423,66 @@ 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::<Vec<_>>());
|
||||
flown += r.launched;
|
||||
crashed += r.crashed.len() as u32;
|
||||
}
|
||||
}
|
||||
assert!(crashed * 40 <= flown, "{crashed} of {flown} flew into the land");
|
||||
}
|
||||
|
||||
/// Steep forested hills: an interceptor that is fired is one the land lets through. The launch check asks the same
|
||||
/// of a shot as the flight does (the margin over the land, the loft), so few blow up short of a ridge.
|
||||
#[test]
|
||||
fn on_steep_forested_hills_few_interceptors_are_scrubbed() {
|
||||
let (mut fired, mut scrubbed) = (0, 0);
|
||||
for seed in 1..=3u64 {
|
||||
let mut s = Setup::new(15, false, seed);
|
||||
s.radars = 2;
|
||||
s.objects = 4;
|
||||
s.hilly = true;
|
||||
s.steep = 1.6;
|
||||
s.trees = true;
|
||||
let r = fly(&s);
|
||||
fired += r.missiles;
|
||||
scrubbed += r.scrubbed;
|
||||
}
|
||||
eprintln!("scrubbed {scrubbed} of {fired}");
|
||||
assert!(scrubbed * 100 <= fired * 10, "{scrubbed} of {fired} interceptors blew up short of a ridge");
|
||||
}
|
||||
|
||||
/// 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 +513,67 @@ 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<DroneKind, (u32, u32)> = HashMap::new();
|
||||
let (mut launched, mut kills, mut leaked, mut tickets) = (0, 0, 0, 0);
|
||||
for seed in std::env::var("SEED").ok().and_then(|v| v.parse::<u64>().ok()).map_or(1..=8u64, |v| v..=v) {
|
||||
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.clone(), k.1.0)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Interceptors that blew up short of a ridge (`STEEP=1.6`, `SAMS=15`, `HILLS=0` for flat): by kind, and what the target was doing.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scrub_table() {
|
||||
let n: usize = std::env::var("SAMS").ok().and_then(|v| v.parse().ok()).unwrap_or(15);
|
||||
let hilly = std::env::var("HILLS").map_or(true, |v| v != "0");
|
||||
let (mut fired, mut scrubbed, mut kills, mut launched, mut leaked) = (0, 0, 0, 0, 0);
|
||||
let mut by: HashMap<String, (u32, u32)> = HashMap::new();
|
||||
let (mut near, mut low) = (0, 0);
|
||||
for seed in 1..=8u64 {
|
||||
let mut s = Setup::new(n, false, seed);
|
||||
s.radars = 2;
|
||||
s.objects = 4;
|
||||
s.hilly = hilly;
|
||||
let r = fly(&s);
|
||||
fired += r.missiles;
|
||||
scrubbed += r.scrubbed;
|
||||
kills += r.kills;
|
||||
launched += r.launched;
|
||||
leaked += r.leaked;
|
||||
for c in &r.scrubs {
|
||||
by.entry(format!("{:?}", c.0)).or_default().0 += 1;
|
||||
near += u32::from(c.2 < 150.0);
|
||||
low += u32::from(c.3 < 30.0);
|
||||
}
|
||||
}
|
||||
let mut v: Vec<_> = by.into_iter().collect();
|
||||
v.sort();
|
||||
eprintln!("sams {n} hilly {hilly}: launched {launched} kills {kills} leaked {leaked} missiles {fired} scrubbed {scrubbed} (target within 150: {near}, target under 30 over land: {low}) by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Fire doctrine (#47): what a system is told about its job beyond the roles. Protected objects (it fires
|
||||
//! only at what threatens them), a sector of responsibility, a reserve of missiles held back for what
|
||||
//! matters, and how many interceptors it asks for on one target. The predicates are shared by `step_sams`
|
||||
//! (through `why::basis`) and the diagnostic; the default doctrine blocks nothing and changes nothing.
|
||||
//! Everything is judged on the system's track (its forecast), never on the drone's true place.
|
||||
use super::*;
|
||||
|
||||
/// How many interceptors a system asks for on one target: the base shoot-look-shoot only, as now, or one more.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum Salvo {
|
||||
Economy,
|
||||
#[default]
|
||||
Normal,
|
||||
Heavy,
|
||||
}
|
||||
|
||||
impl Salvo {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Salvo::Economy => "экономный",
|
||||
Salvo::Normal => "обычный",
|
||||
Salvo::Heavy => "усиленный",
|
||||
}
|
||||
}
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Salvo::Economy => "eco",
|
||||
Salvo::Normal => "normal",
|
||||
Salvo::Heavy => "heavy",
|
||||
}
|
||||
}
|
||||
pub fn from_key(s: &str) -> Option<Salvo> {
|
||||
[Salvo::Economy, Salvo::Normal, Salvo::Heavy].into_iter().find(|v| v.key() == s)
|
||||
}
|
||||
pub fn next(self) -> Salvo {
|
||||
match self {
|
||||
Salvo::Economy => Salvo::Normal,
|
||||
Salvo::Normal => Salvo::Heavy,
|
||||
Salvo::Heavy => Salvo::Economy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A system's doctrine. `Default` is the sandbox: no guards, no sector, no reserve, the usual salvo.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Doctrine {
|
||||
/// Protected objects (their places): when any, the system fires only at what threatens one of them.
|
||||
pub guards: Vec<DVec3>,
|
||||
/// Sector of responsibility: centre (a yaw as `heading_of`, radians) and half-width.
|
||||
pub sector: Option<(f64, f64)>,
|
||||
/// Missiles held back: below this many, only what matters is shot at.
|
||||
pub reserve: u32,
|
||||
pub salvo: Salvo,
|
||||
}
|
||||
|
||||
/// Where a track counts as threatening an object: within this many blocks of it, or flying at it.
|
||||
pub const GUARD_R: f64 = 120.0;
|
||||
/// Most missiles one can reserve.
|
||||
pub const MAX_RESERVE: u32 = 64;
|
||||
|
||||
pub(super) const OUT_SECTOR: u8 = 1;
|
||||
pub(super) const NO_THREAT: u8 = 2;
|
||||
pub(super) const RESERVE: u8 = 4;
|
||||
|
||||
impl Doctrine {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Doctrine::default()
|
||||
}
|
||||
|
||||
/// Whether the track is within the sector (judged from the system's place).
|
||||
pub fn in_sector(&self, pos: DVec3, at: DVec3) -> bool {
|
||||
self.sector.is_none_or(|(centre, half)| {
|
||||
let to = at - pos;
|
||||
to.x.hypot(to.z) < 30.0 || angle_diff(centre, heading_of(to)).abs() <= half
|
||||
})
|
||||
}
|
||||
|
||||
/// The first protected object the track threatens (its index in `guards`): near it, or flying towards it.
|
||||
pub fn threatens(&self, track: &Track, now: u64) -> Option<usize> {
|
||||
let at = track.est(now);
|
||||
let slack = GUARD_R + track.uncertainty(now);
|
||||
self.guards.iter().position(|g| {
|
||||
let (dx, dz) = (g.x - at.x, g.z - at.z);
|
||||
let near = dx.hypot(dz);
|
||||
if near <= slack {
|
||||
return true;
|
||||
}
|
||||
let (vx, vz) = (track.vel.x, track.vel.z);
|
||||
let speed = vx.hypot(vz);
|
||||
// Closing, and the line of flight passes the object within the slack.
|
||||
speed > 1e-3 && vx * dx + vz * dz > 0.0 && (vx * dz - vz * dx).abs() / speed <= slack
|
||||
})
|
||||
}
|
||||
|
||||
/// Why the area (sector, protected objects) rules the track out; 0 is a pass.
|
||||
pub fn area_blocks(&self, pos: DVec3, track: &Track, now: u64) -> u8 {
|
||||
(if self.in_sector(pos, track.est(now)) { 0 } else { OUT_SECTOR }) | (if self.guards.is_empty() || self.threatens(track, now).is_some() { 0 } else { NO_THREAT })
|
||||
}
|
||||
|
||||
/// What matters enough to spend the reserve: a ballistic missile, or anything about to hit.
|
||||
pub fn matters(d: &Drone) -> bool {
|
||||
d.kind.spec().class == Class::Ballistic || why::arrival(d) < why::URGENT
|
||||
}
|
||||
|
||||
/// The reserve holds the launch back: the magazine is at the reserve and the target doesn't matter.
|
||||
pub fn reserve_blocks(&self, missiles: u32, d: &Drone) -> u8 {
|
||||
if self.reserve > 0 && missiles <= self.reserve && !Self::matters(d) { RESERVE } else { 0 }
|
||||
}
|
||||
|
||||
/// Interceptors this doctrine asks for on `d`: `why::needed` by default, the base only when economical, one more when heavy.
|
||||
pub fn needed(&self, d: &Drone) -> u32 {
|
||||
let base = if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
|
||||
match self.salvo {
|
||||
Salvo::Economy => base,
|
||||
Salvo::Normal => why::needed(d),
|
||||
Salvo::Heavy => why::needed(d) + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sector in words (`90±45°`); degrees on the same scale as `/airdef doctrine ... sector`.
|
||||
pub fn sector_text(&self) -> String {
|
||||
match self.sector {
|
||||
Some((c, h)) => format!("{:.0}±{:.0}°", c.to_degrees().rem_euclid(360.0), h.to_degrees()),
|
||||
None => "круговой".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The doctrine as the player reads it, empty for the default.
|
||||
pub fn text(&self, targets: &[DVec3]) -> String {
|
||||
if self.is_default() {
|
||||
return "обычная (без ограничений)".to_string();
|
||||
}
|
||||
let guards = if self.guards.is_empty() {
|
||||
"нет".to_string()
|
||||
} else {
|
||||
self.guards.iter().map(|g| object_name(targets, *g)).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
format!("охрана: {guards}; сектор: {}; резерв: {}; залп: {}", self.sector_text(), self.reserve, self.salvo.name())
|
||||
}
|
||||
|
||||
/// One line of the save: `salvo reserve sector-centre sector-half guards x y z ...` (`-` for no sector).
|
||||
pub fn save_text(&self) -> String {
|
||||
let sector = self.sector.map_or("- -".to_string(), |(c, h)| format!("{c} {h}"));
|
||||
let mut s = format!("{} {} {sector} {}", self.salvo.key(), self.reserve, self.guards.len());
|
||||
for g in &self.guards {
|
||||
s += &format!(" {} {} {}", g.x, g.y, g.z);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// An object by number in the world's list ("объект №2"), else by place.
|
||||
pub fn object_name(targets: &[DVec3], g: DVec3) -> String {
|
||||
match targets.iter().position(|t| t.distance(g) < 16.0) {
|
||||
Some(i) => format!("объект №{}", i + 1),
|
||||
None => format!("точка {:.0} {:.0}", g.x, g.z),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track(pos: DVec3, vel: DVec3) -> Track {
|
||||
Track { id: 1, pos, vel, tick: 0, own_tick: 0, ttl: 100, hits: 3, locked: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_doctrine_blocks_nothing() {
|
||||
let d = Doctrine::default();
|
||||
let t = track(DVec3::new(500.0, 100.0, 0.0), DVec3::ZERO);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &t, 0), 0);
|
||||
assert!(d.is_default());
|
||||
assert_eq!(Doctrine::default().save_text(), "normal 0 - - 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sector_and_guards_rule_targets_out_by_their_tracks() {
|
||||
let mut d = Doctrine::default();
|
||||
// North is yaw 0 (towards -z): a sector of 45 degrees either side of north.
|
||||
d.sector = Some((0.0, 45f64.to_radians()));
|
||||
let north = track(DVec3::new(0.0, 100.0, -800.0), DVec3::ZERO);
|
||||
let south = track(DVec3::new(0.0, 100.0, 800.0), DVec3::ZERO);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &north, 0), 0);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &south, 0), OUT_SECTOR);
|
||||
d.sector = None;
|
||||
d.guards = vec![DVec3::new(1000.0, 70.0, 0.0)];
|
||||
// Flying at the object from afar threatens it; flying past, or away, doesn't.
|
||||
let at = track(DVec3::new(-2000.0, 100.0, 10.0), DVec3::new(2.0, 0.0, 0.0));
|
||||
let past = track(DVec3::new(-2000.0, 100.0, 900.0), DVec3::new(2.0, 0.0, 0.0));
|
||||
let away = track(DVec3::new(-2000.0, 100.0, 0.0), DVec3::new(-2.0, 0.0, 0.0));
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &at, 0), 0);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &past, 0), NO_THREAT);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &away, 0), NO_THREAT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_line_is_what_the_layout_reads() {
|
||||
let mut d = Doctrine { salvo: Salvo::Economy, reserve: 3, ..Default::default() };
|
||||
d.sector = Some((1.5, 0.5));
|
||||
d.guards = vec![DVec3::new(1.0, 2.0, 3.0)];
|
||||
let text = format!("sam {} 0 70 0 0\ndoctrine 0 {}\n", SamKind::Patriot.key(), d.save_text());
|
||||
let l = crate::layout::Layout::parse(&text).unwrap();
|
||||
assert_eq!(l.doctrines, vec![(0, d)]);
|
||||
assert!(crate::layout::Layout::parse("doctrine 0 loud 1 - - 0\n").is_err());
|
||||
assert!(crate::layout::Layout::parse("doctrine 0 eco 1 - - 2 1 2 3\n").is_err(), "guards cut short");
|
||||
}
|
||||
|
||||
use super::sites::testland::{Land, serve};
|
||||
|
||||
/// One Patriot against one Shahed flying in from the north at its aim, the doctrine set before the raid:
|
||||
/// the missiles it fired, the Shahed's ticks to its aim at the first launch, and the `why` lines at the end.
|
||||
fn fire(doc: Doctrine) -> (u32, Option<f64>, Vec<String>) {
|
||||
let mut sim = Sim::new(5);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
sim.sams[0].roles = 0xFF;
|
||||
sim.sams[0].doctrine = doc;
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -600.0), DVec3::Z, DVec3::new(0.0, 70.0, -80.0));
|
||||
let (mut ground, land, mut out) = (Ground::default(), Land(|_, _| 64), Out::default());
|
||||
let mut first = None;
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for _ in 0..900 {
|
||||
serve(&mut ground, &land, sim.tick);
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if first.is_none() && sim.sams[0].fired > 0 {
|
||||
first = sim.drones.first().map(why::arrival);
|
||||
}
|
||||
if sim.drones.iter().all(|d| !d.alive()) {
|
||||
break;
|
||||
}
|
||||
// The explanation while the target is in the sky: all the lines it gave then.
|
||||
if sim.tick % 10 == 0 {
|
||||
lines.extend(sim.why(0).into_iter().map(|c| c.text));
|
||||
}
|
||||
}
|
||||
(sim.sams[0].fired, first, lines)
|
||||
}
|
||||
|
||||
// #47: the defaults fire as before; each setting changes the decision and `why` names it
|
||||
#[test]
|
||||
fn each_setting_changes_the_shot_and_why_says_which() {
|
||||
let (base, first, w) = fire(Doctrine::default());
|
||||
assert!(base > 0, "the baseline never fired: {w:?}");
|
||||
let first = first.unwrap();
|
||||
assert!(first > why::URGENT, "the baseline fires late: {first}");
|
||||
let has = |text: &[String], what: &str| text.iter().any(|l| l.contains(what));
|
||||
// The raid comes from the north (yaw 0): a sector round north holds it, one round south does not.
|
||||
let sector = |c: f64| Doctrine { sector: Some((f64::to_radians(c), f64::to_radians(40.0))), ..Default::default() };
|
||||
assert_eq!(fire(sector(0.0)).0, base, "a sector that holds the raid changes nothing");
|
||||
let (n, _, why) = fire(sector(180.0));
|
||||
assert_eq!(n, 0);
|
||||
assert!(has(&why, "вне сектора"), "{why:?}");
|
||||
// A reserve above the magazine holds the shot until the target is urgent.
|
||||
let (n, late, _) = fire(Doctrine { reserve: 64, ..Default::default() });
|
||||
assert!(n > 0 && late.unwrap() < why::URGENT, "{n} {late:?}");
|
||||
// An object far off the raid's line is no one's to guard here; the one it flies at is.
|
||||
let (n, _, why) = fire(Doctrine { guards: vec![DVec3::new(0.0, 70.0, 50000.0)], ..Default::default() });
|
||||
assert_eq!(n, 0);
|
||||
assert!(has(&why, "не угрожает"), "{why:?}");
|
||||
let (n, _, why) = fire(Doctrine { guards: vec![DVec3::new(0.0, 70.0, -80.0)], ..Default::default() });
|
||||
assert_eq!(n, base);
|
||||
assert!(has(&why, "прикрывает"), "{why:?}");
|
||||
// Economical asks for no more interceptors than the usual, heavy no fewer.
|
||||
let (eco, _, _) = fire(Doctrine { salvo: Salvo::Economy, ..Default::default() });
|
||||
let (heavy, _, _) = fire(Doctrine { salvo: Salvo::Heavy, ..Default::default() });
|
||||
assert!(eco <= base && base <= heavy, "{eco} {base} {heavy}");
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
let at = truck.pos;
|
||||
let truck_id = truck.id;
|
||||
// Where it was going: hand over, or load up.
|
||||
if let Some(job) = truck.job
|
||||
&& let Some(j) = self.sams.iter().position(|s| s.id == job)
|
||||
@@ -73,7 +74,7 @@ impl Sim {
|
||||
// Stopped short of it (it drove off meanwhile): on to where it stands now.
|
||||
let to = self.sams[j].pos;
|
||||
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
|
||||
self.order_move(t, to + side);
|
||||
self.order_move_near(t, to + side, 3.5);
|
||||
continue;
|
||||
} else {
|
||||
self.sams[t].job = None;
|
||||
@@ -84,7 +85,7 @@ impl Sim {
|
||||
let next = if self.sams[t].cargo > 0 {
|
||||
self.sams
|
||||
.iter()
|
||||
.filter(|s| s.empty && s.alive() && !s.moving() && !taken.contains(&s.id))
|
||||
.filter(|s| s.empty && s.alive() && !s.moving() && !taken.contains(&s.id) && !self.routes.out_of_reach(truck_id, s.id, now))
|
||||
.min_by(|a, b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
|
||||
.map(|s| (s.id, s.pos))
|
||||
} else {
|
||||
@@ -95,7 +96,7 @@ impl Sim {
|
||||
.then(|| {
|
||||
self.sams
|
||||
.iter()
|
||||
.filter(|d| d.kind == SamKind::Depot && d.alive() && d.cargo > 0)
|
||||
.filter(|d| d.kind == SamKind::Depot && d.alive() && d.cargo > 0 && !self.routes.out_of_reach(truck_id, d.id, now))
|
||||
.min_by(|a, b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
|
||||
.map(|d| (d.id, d.pos))
|
||||
})
|
||||
@@ -106,7 +107,7 @@ impl Sim {
|
||||
// Stop beside it, not on it.
|
||||
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
|
||||
if to.distance(at) > HANDOVER {
|
||||
self.order_move(t, to + side);
|
||||
self.order_move_near(t, to + side, 3.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-12
@@ -10,9 +10,11 @@ use glam::{DQuat, DVec3};
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod damage;
|
||||
pub mod doctrine;
|
||||
pub mod fires;
|
||||
mod raid;
|
||||
pub mod sites;
|
||||
pub mod strike;
|
||||
pub mod record;
|
||||
mod sight;
|
||||
mod terrain;
|
||||
@@ -20,6 +22,7 @@ mod ew;
|
||||
pub mod lights;
|
||||
mod logistics;
|
||||
mod orders;
|
||||
mod route;
|
||||
mod voices;
|
||||
pub mod outcome;
|
||||
mod units;
|
||||
@@ -33,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};
|
||||
@@ -353,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.
|
||||
@@ -917,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1272,6 +1293,8 @@ pub struct Sam {
|
||||
pub fired: u32,
|
||||
/// The classes of target it engages (`Class::bit`s).
|
||||
pub roles: u8,
|
||||
/// Its fire doctrine (#47): protected objects, sector, reserve, salvo.
|
||||
pub doctrine: doctrine::Doctrine,
|
||||
/// 1 whole, 0 destroyed: a damaged system turns slower, sees less and hits worse.
|
||||
pub health: f64,
|
||||
/// Ticks its wreck still burns.
|
||||
@@ -1344,6 +1367,7 @@ impl Sam {
|
||||
kills: 0,
|
||||
fired: 0,
|
||||
roles: spec.roles,
|
||||
doctrine: doctrine::Doctrine::default(),
|
||||
state: SamState::Search,
|
||||
launched: 0,
|
||||
health: 1.0,
|
||||
@@ -1486,6 +1510,14 @@ 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)>,
|
||||
/// Interceptors that blew up short of a ridge: kind, age, distance to the target, the target's height over the land under it (balance measurements only).
|
||||
#[cfg(test)]
|
||||
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
|
||||
pub launched: u32,
|
||||
/// Explosions damage the land (primed TNT through commands).
|
||||
pub damage: bool,
|
||||
@@ -1509,8 +1541,12 @@ pub struct Sim {
|
||||
pub queue: Vec<(u64, Drone)>,
|
||||
/// The raid's launch sites and their jobs (#100).
|
||||
pub sites: sites::Sites,
|
||||
/// Launch sites found by the radars, and the counter-strikes on them (#101).
|
||||
pub strikes: strike::Strikes,
|
||||
/// A raid being planned.
|
||||
plan: Option<raid::Plan>,
|
||||
/// The vehicles' ground routes, and the jobs the trucks found out of reach (#94).
|
||||
routes: route::Routes,
|
||||
/// Each marked object's health, 0 to 100 (`targets`' order).
|
||||
pub target_health: Vec<f64>,
|
||||
/// The raid in progress, for its report.
|
||||
@@ -1568,6 +1604,11 @@ impl Sim {
|
||||
kills: 0,
|
||||
hits: 0,
|
||||
crashes: 0,
|
||||
settling: Vec::new(),
|
||||
#[cfg(test)]
|
||||
crashed: Vec::new(),
|
||||
#[cfg(test)]
|
||||
scrubs: Vec::new(),
|
||||
launched: 0,
|
||||
damage: true,
|
||||
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
|
||||
@@ -1580,7 +1621,9 @@ impl Sim {
|
||||
later: Vec::new(),
|
||||
queue: Vec::new(),
|
||||
sites: sites::Sites::default(),
|
||||
strikes: strike::Strikes::new(),
|
||||
plan: None,
|
||||
routes: Default::default(),
|
||||
target_health: Vec::new(),
|
||||
wave: None,
|
||||
rec: None,
|
||||
@@ -1695,12 +1738,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<DVec3>, 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);
|
||||
@@ -1710,6 +1784,7 @@ impl Sim {
|
||||
self.step_lights(ground, out);
|
||||
self.step_sams(ground, out);
|
||||
self.step_missiles(ground, out);
|
||||
self.step_strikes(ground, player, out);
|
||||
self.step_fx(ground, out);
|
||||
self.draw_targets(out);
|
||||
self.draw_sites(out);
|
||||
@@ -1741,11 +1816,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;
|
||||
}
|
||||
@@ -1807,6 +1883,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;
|
||||
}
|
||||
@@ -1908,7 +1988,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 {
|
||||
@@ -1941,13 +2023,18 @@ 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 mut height = base.max(safe).max(d.nav.dive + 1.0);
|
||||
// Blind: it does not come down over land nobody has told of; it holds the height it has.
|
||||
if d.nav.blind && d.low && to_target > 300.0 {
|
||||
height = height.max(d.pos.y);
|
||||
}
|
||||
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 {
|
||||
@@ -1984,6 +2071,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;
|
||||
@@ -2359,7 +2450,8 @@ impl Sim {
|
||||
// the forecast of the last measurement, never the true place.
|
||||
let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]);
|
||||
let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target);
|
||||
let needed = why::needed;
|
||||
let doc = &me.doctrine;
|
||||
let needed = |d: &Drone| doc.needed(d);
|
||||
let opts = why::Opts { last_chance: self.last_chance, far_shots: self.far_shots };
|
||||
let target = spec.missile.as_ref().map(|load| {
|
||||
// A launcher with nothing to fire (reloading, empty, failed) claims no target: others take it.
|
||||
@@ -2396,7 +2488,7 @@ impl Sim {
|
||||
tracks
|
||||
.iter()
|
||||
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
|
||||
.filter(|(tr, d)| why::gun_candidate(roles, tr, now, gun.track, pos, d))
|
||||
.filter(|(tr, d)| why::gun_candidate(roles, doc, tr, now, gun.track, pos, d))
|
||||
.map(|(tr, d)| {
|
||||
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
|
||||
(tr.est(now).distance(pos) * busy, d.id)
|
||||
@@ -2688,6 +2780,8 @@ impl Sim {
|
||||
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
|
||||
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
||||
let (mut blocked, mut scrubbed) = (0u32, 0u32);
|
||||
#[cfg(test)]
|
||||
let mut scrub_log = Vec::new();
|
||||
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect();
|
||||
// Where each launcher stands and how far its missiles reach: a hit past that is less likely (`why::reach_fit`).
|
||||
let launchers: HashMap<u32, (DVec3, f64)> = self.sams.iter().filter_map(|s| Some((s.id, (s.pos, s.kind.spec().missile.as_ref()?.max)))).collect();
|
||||
@@ -2748,9 +2842,19 @@ impl Sim {
|
||||
let closing = (speed - vel.dot(los)).max(1.0);
|
||||
let tgo = at.distance(m.pos) / closing;
|
||||
// Over the land in the way: lofted as far as the missile can, else it blows up before the ridge.
|
||||
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo);
|
||||
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo, now);
|
||||
if fail {
|
||||
scrubbed += 1;
|
||||
#[cfg(test)]
|
||||
{
|
||||
let over = target.and_then(|t| ground.at(t.pos, now).map(|g| t.pos.y - g)).unwrap_or(f64::NAN);
|
||||
let dist = target.map_or(f64::NAN, |t| t.pos.distance(m.pos));
|
||||
let mover = ground.at(m.pos, now).map_or(f64::NAN, |g| m.pos.y - g);
|
||||
let from = launchers.get(&m.sam).map_or(0.0, |l| m.pos.distance(l.0));
|
||||
if scrub_log.len() < 30 {
|
||||
scrub_log.push((m.kind, m.age, dist, over, m.pos, mover, from));
|
||||
}
|
||||
}
|
||||
done.push((m.id, target.map(|t| t.id), false, m.sam));
|
||||
continue;
|
||||
}
|
||||
@@ -2840,6 +2944,8 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
self.scrubs.extend(scrub_log);
|
||||
self.note(|t| {
|
||||
t.missed += missed;
|
||||
t.expired += expired;
|
||||
|
||||
@@ -38,11 +38,18 @@ impl Sam {
|
||||
impl Sim {
|
||||
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck, failed running gear).
|
||||
pub fn order_move(&mut self, i: usize, to: DVec3) -> bool {
|
||||
self.order_move_near(i, to, 0.0)
|
||||
}
|
||||
|
||||
/// The same, where arriving within `slack` blocks of `to` will do (a truck stops beside its job).
|
||||
pub fn order_move_near(&mut self, i: usize, to: DVec3, slack: f64) -> bool {
|
||||
let Some(sam) = self.sams.get_mut(i) else { return false };
|
||||
if !sam.alive() || !sam.kind.mobile() || sam.drive_speed() <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
sam.dest = Some(to);
|
||||
let route = route::Route::new(sam.pos, to, slack, sam.kind.climbs(), self.tick);
|
||||
self.routes.map.insert(sam.id, route);
|
||||
sam.target = None;
|
||||
sam.gun_target = None;
|
||||
if sam.state != SamState::Moving {
|
||||
@@ -52,11 +59,17 @@ impl Sim {
|
||||
true
|
||||
}
|
||||
|
||||
/// Packing, driving and deploying, once a tick.
|
||||
/// Packing, driving and deploying, once a tick. The way is searched on the ground the game has told of (a
|
||||
/// few hundred cells a tick for all the vehicles); a vehicle stands while it waits, and stops with a word
|
||||
/// when there is no way.
|
||||
pub(super) fn step_moves(&mut self, ground: &mut Ground, out: &mut Out) {
|
||||
let now = self.tick;
|
||||
for sam in &mut self.sams {
|
||||
let planners = self.routes.map.values().filter(|r| r.searching()).count().max(1);
|
||||
let share = route::WORK_PER_TICK / planners;
|
||||
let Sim { sams, routes, .. } = self;
|
||||
for (n, sam) in sams.iter_mut().enumerate() {
|
||||
if !sam.alive() {
|
||||
routes.map.remove(&sam.id);
|
||||
continue;
|
||||
}
|
||||
let spec = sam.kind.spec();
|
||||
@@ -66,12 +79,16 @@ impl Sim {
|
||||
}
|
||||
match sam.state {
|
||||
SamState::Packing => {
|
||||
// The launcher comes down for the march.
|
||||
// The launcher comes down for the march; the way is searched meanwhile.
|
||||
sam.pitch += (0.0 - sam.pitch) * 0.06;
|
||||
sam.yaw = slew(sam.yaw, sam.heading, 0.04);
|
||||
sam.march = sam.march.saturating_sub(1);
|
||||
if sam.march == 0 {
|
||||
sam.state = SamState::Moving;
|
||||
} else if let Some(r) = routes.map.get_mut(&sam.id)
|
||||
&& r.searching()
|
||||
{
|
||||
let _ = r.drive(ground, now, sam.pos, share);
|
||||
}
|
||||
}
|
||||
SamState::Moving => {
|
||||
@@ -80,14 +97,43 @@ impl Sim {
|
||||
sam.dest = None;
|
||||
}
|
||||
let Some(to) = sam.dest else {
|
||||
routes.map.remove(&sam.id);
|
||||
sam.state = SamState::Deploying;
|
||||
sam.march = sam.kind.march_ticks();
|
||||
continue;
|
||||
};
|
||||
let flat = horizontal(to - sam.pos);
|
||||
if flat.length() < SPEED * 2.0 {
|
||||
sam.pos = DVec3::new(to.x, sam.pos.y, to.z);
|
||||
let route = routes.map.entry(sam.id).or_insert_with(|| route::Route::new(sam.pos, to, 0.0, sam.kind.climbs(), now));
|
||||
let (go, last) = match route.drive(ground, now, sam.pos, share) {
|
||||
route::Drive::Wait(why) => {
|
||||
if route.waited(now) > 40 && !route.told {
|
||||
route.told = true;
|
||||
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, why.text()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
route::Drive::Fail(why) => {
|
||||
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, why.text()));
|
||||
// A truck leaves that job alone for a while, and takes another.
|
||||
if let Some(job) = sam.job.take() {
|
||||
routes.unreachable.insert((sam.id, job), now + route::UNREACHABLE_FOR);
|
||||
}
|
||||
routes.map.remove(&sam.id);
|
||||
sam.dest = None;
|
||||
sam.state = SamState::Deploying;
|
||||
sam.march = sam.kind.march_ticks();
|
||||
continue;
|
||||
}
|
||||
route::Drive::Go(p, last) => (p, last),
|
||||
};
|
||||
let flat = horizontal(go - sam.pos);
|
||||
if last && flat.length() < SPEED * 2.0 {
|
||||
let partial = route.partial;
|
||||
sam.pos = DVec3::new(go.x, sam.pos.y, go.z);
|
||||
sam.dest = None;
|
||||
routes.map.remove(&sam.id);
|
||||
if partial {
|
||||
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::Partial.text()));
|
||||
}
|
||||
sam.state = SamState::Deploying;
|
||||
sam.march = sam.kind.march_ticks();
|
||||
out.sound("minecraft:block.piston.contract", sam.pos, 2.0, 0.6);
|
||||
@@ -99,8 +145,24 @@ impl Sim {
|
||||
let turning = angle_diff(sam.heading, heading_of(flat)).abs();
|
||||
let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2);
|
||||
let next = sam.pos + ahead * step;
|
||||
let y = ground.at(next, now).unwrap_or(sam.pos.y);
|
||||
sam.pos = DVec3::new(next.x, sam.pos.y + (y - sam.pos.y).clamp(-0.5, 0.5), next.z);
|
||||
// The column ahead: a wall or a drop the grid missed turns the vehicle back to the planner.
|
||||
let Some(y) = ground.at(next, now) else {
|
||||
if route.wait_ahead() {
|
||||
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::NoMap.text()));
|
||||
routes.map.remove(&sam.id);
|
||||
sam.dest = None;
|
||||
sam.state = SamState::Deploying;
|
||||
sam.march = sam.kind.march_ticks();
|
||||
}
|
||||
continue;
|
||||
};
|
||||
route.ahead_ok();
|
||||
let rise = y - sam.pos.y;
|
||||
if rise > route::STEP_UP || rise < -route::STEP_DOWN {
|
||||
route.blocked_ahead(next, sam.pos);
|
||||
continue;
|
||||
}
|
||||
sam.pos = DVec3::new(next.x, sam.pos.y + rise.clamp(-0.5, 0.5), next.z);
|
||||
sam.yaw = sam.heading;
|
||||
if now % 4 == 0 {
|
||||
out.particles("minecraft:campfire_cosy_smoke", sam.pos - ahead * 2.0 * spec.scale, 0.3, DVec3::new(0.0, 0.03, 0.0), 0.01, 1);
|
||||
|
||||
@@ -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,11 +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.
|
||||
match self.site_slot(d.id) {
|
||||
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;
|
||||
@@ -672,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);
|
||||
@@ -820,6 +864,13 @@ impl Sim {
|
||||
if !why.is_empty() {
|
||||
out.messages.push(format!("Неудачи ПВО: {why}"));
|
||||
}
|
||||
// What the counter-strikes did to the raid (#101): the launches that never happened.
|
||||
let cut: u32 = self.strikes.cancelled.iter().map(|c| c.1).sum();
|
||||
if cut > 0 {
|
||||
let sites: Vec<String> = self.strikes.cancelled.iter().filter(|c| c.1 > 0).map(|c| format!("№{}", c.0)).collect();
|
||||
out.messages.push(format!("Налёт сорван ударами по ПУ ({}): не взлетело {cut} из заявленных {}", sites.join(", "), w.size));
|
||||
}
|
||||
self.strikes.cancelled.clear();
|
||||
self.wave = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,22 @@ pub enum EventKind {
|
||||
Split { drone: u32 },
|
||||
/// A drone's warhead went off against the land or a building short of its aim.
|
||||
Crash { drone: u32, kind: DroneKind, power: u32 },
|
||||
/// A counter-strike on a launch site landed (#101): where it landed and how it ended. Launch sites themselves are
|
||||
/// not drawn in a replay.
|
||||
Strike { site: u32, result: StrikeResult },
|
||||
}
|
||||
|
||||
/// How a strike on a launch site ended.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum StrikeResult {
|
||||
Destroyed,
|
||||
/// The site was stunned for a while.
|
||||
Stunned,
|
||||
Missed,
|
||||
/// Shot down by the enemy's cover.
|
||||
Intercepted,
|
||||
/// Off the map: its launches ended, nothing to see.
|
||||
Halted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -336,6 +352,16 @@ pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
|
||||
(size, life, gain)
|
||||
}
|
||||
|
||||
/// The fire a strike's end leaves (0: none): the wreck of a site burns long, a crater a while.
|
||||
pub fn strike_power(result: StrikeResult) -> u32 {
|
||||
match result {
|
||||
StrikeResult::Destroyed => 3,
|
||||
StrikeResult::Stunned => 2,
|
||||
StrikeResult::Missed => 1,
|
||||
StrikeResult::Intercepted | StrikeResult::Halted => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
|
||||
pub fn blast_spec(kind: EventKind) -> (f32, u32) {
|
||||
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
|
||||
@@ -351,6 +377,12 @@ pub fn blast_spec(kind: EventKind) -> (f32, u32) {
|
||||
}
|
||||
EventKind::SamLost { .. } => (8.0, 18 * 9),
|
||||
EventKind::Split { .. } => (2.0, 7 * 9),
|
||||
EventKind::Strike { result, .. } => match result {
|
||||
StrikeResult::Destroyed => (SIZE[4], LIFE[4] * 9),
|
||||
StrikeResult::Stunned | StrikeResult::Missed => (SIZE[3], LIFE[3] * 9),
|
||||
StrikeResult::Intercepted => (SIZE[0], LIFE[0] * 9),
|
||||
StrikeResult::Halted => (0.0, 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +414,7 @@ pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
|
||||
EventKind::Crash { power, .. } => power,
|
||||
EventKind::Hit { power, .. } => power.max(1),
|
||||
EventKind::SamLost { .. } => 2,
|
||||
EventKind::Strike { result, .. } => strike_power(result),
|
||||
_ => continue,
|
||||
};
|
||||
let (size, life) = (fires::size_for(power), fires::life_for(power));
|
||||
@@ -518,6 +551,7 @@ impl Sim {
|
||||
self.ignite(pos, power.max(1), host)
|
||||
}
|
||||
EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)),
|
||||
EventKind::Strike { result, .. } if strike_power(result) > 0 => self.ignite(pos, strike_power(result), None),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(r) = &mut self.rec {
|
||||
|
||||
@@ -1594,3 +1594,120 @@ 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}");
|
||||
}
|
||||
|
||||
/// The height map is a coarse upper bound (a forest's canopy shows as land): an interceptor under it, with the blocks
|
||||
/// around it open, is not blown up for "no way over"; it is blown up only before blocks that are really there.
|
||||
#[test]
|
||||
fn an_interceptor_under_the_canopy_is_not_scrubbed_before_open_air() {
|
||||
let blocks = sites::testland::Land(|_, _| 64);
|
||||
let steer = |solid_to: i32| {
|
||||
let mut ground = Ground::default();
|
||||
let land = sites::testland::Land(move |_, _| solid_to);
|
||||
let kind = MissileKind::M57e6;
|
||||
let pos = DVec3::new(0.0, 66.0, 0.0);
|
||||
let mut fail = false;
|
||||
for t in 0..30 {
|
||||
// The heights say 76 (the canopy); the blocks are solid only up to `solid_to`.
|
||||
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, 76)).collect();
|
||||
ground.answer(cols, t);
|
||||
let secs = ground.world.take_wanted(t);
|
||||
ground.world.serve(&land, secs, t);
|
||||
fail = terrain::steer(&mut ground, kind, pos, kind.spec().speed, DVec3::new(0.0, 70.0, 200.0), t).1;
|
||||
}
|
||||
fail
|
||||
};
|
||||
let _ = blocks;
|
||||
assert!(!steer(64), "open air under the canopy");
|
||||
assert!(steer(80), "real blocks ahead");
|
||||
}
|
||||
|
||||
/// A flight that is high over land nobody has told of does not come down towards the height of its aim.
|
||||
#[test]
|
||||
fn over_unknown_land_a_flight_does_not_descend() {
|
||||
let aim = DVec3::new(0.0, 64.0, 3000.0);
|
||||
let mut sim = Sim::new(4);
|
||||
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 250.0, 0.0), DVec3::Z, aim);
|
||||
d.low = true;
|
||||
d.route = vec![aim];
|
||||
sim.drones.push(d);
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
for _ in 0..150 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.drones[0].pos.y > 245.0, "it came down over unknown land: {}", sim.drones[0].pos.y);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
//! Ground routes for the vehicles (#94): a march is no longer a straight line through the relief. A bounded
|
||||
//! A* over a coarse grid of the land the game has told of finds a way that avoids cliffs, water and walls; it
|
||||
//! is worked on a few hundred cells a tick, shared by all the vehicles, and it keeps its state between ticks.
|
||||
//! Land the game has not answered yet is waited for (the search parks those edges and goes on elsewhere), and
|
||||
//! after a while the vehicle goes as far as the known land; a place nobody can reach is refused with a reason.
|
||||
//! While driving, the column ahead is checked once more (a wall the grid missed, a drop): the cell is marked bad
|
||||
//! and the way is searched again.
|
||||
use super::*;
|
||||
use std::collections::{BinaryHeap, HashSet};
|
||||
|
||||
/// Cells opened a tick, all vehicles together.
|
||||
pub(super) const WORK_PER_TICK: usize = 600;
|
||||
/// Cells opened for one route before it settles for the nearest it got.
|
||||
const MAX_NODES: usize = 24_000;
|
||||
/// Ticks the search waits for land the game has not told of, before the vehicle goes as far as is known.
|
||||
const WAIT_MAP: u64 = 300;
|
||||
/// Ticks a driver waits for the column ahead.
|
||||
const WAIT_AHEAD: u32 = 200;
|
||||
/// Searches again, after a way was found blocked on the march, before giving up.
|
||||
const REPLANS: u32 = 16;
|
||||
/// The tallest step a vehicle climbs between two columns, and the deepest drop it takes.
|
||||
pub(super) const STEP_UP: f64 = 1.5;
|
||||
pub(super) const STEP_DOWN: f64 = 3.5;
|
||||
/// Ticks a truck keeps away from a job it found unreachable.
|
||||
pub(super) const UNREACHABLE_FOR: u64 = 3600;
|
||||
|
||||
/// Why a vehicle waits or stops.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Why {
|
||||
/// Waiting for the game's answers about the land.
|
||||
Map,
|
||||
/// The place itself can't be driven to (cliff, water, wall).
|
||||
Goal,
|
||||
/// No way there over the known land.
|
||||
NoWay,
|
||||
/// The way was found blocked on the march, again and again.
|
||||
Stuck,
|
||||
/// The map did not come.
|
||||
NoMap,
|
||||
/// It drove as far as the land is known.
|
||||
Partial,
|
||||
}
|
||||
|
||||
impl Why {
|
||||
pub fn text(self) -> &'static str {
|
||||
match self {
|
||||
Why::Map => "ждёт карту местности",
|
||||
Why::Goal => "туда не проехать: обрыв, вода или стена в точке назначения",
|
||||
Why::NoWay => "прохода нет: путь перекрыт обрывами, водой или стенами",
|
||||
Why::Stuck => "путь перекрыт на марше, объезда нет",
|
||||
Why::NoMap => "карта местности не пришла, остался на месте",
|
||||
Why::Partial => "дальше карта неизвестна: остановился на краю известного",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the route says a vehicle does this tick.
|
||||
pub(super) enum Drive {
|
||||
/// Stand: for the land, or the planner.
|
||||
Wait(Why),
|
||||
/// Drive at this point; whether it is the last, and the route ends short of the order.
|
||||
Go(DVec3, bool),
|
||||
/// Stop here, and say why.
|
||||
Fail(Why),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Info {
|
||||
h: f32,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
enum Eval {
|
||||
Ok(f32),
|
||||
Bad,
|
||||
Pending,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct Open(f32, (i32, i32));
|
||||
impl Eq for Open {}
|
||||
impl Ord for Open {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
other.0.total_cmp(&self.0)
|
||||
}
|
||||
}
|
||||
impl PartialOrd for Open {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Phase {
|
||||
Search,
|
||||
Follow,
|
||||
}
|
||||
|
||||
/// One vehicle's way to its destination.
|
||||
pub(super) struct Route {
|
||||
dest: DVec3,
|
||||
/// How near the destination counts (a truck stops beside its job); 0: the place itself.
|
||||
slack: f64,
|
||||
cell: i32,
|
||||
slope: f64,
|
||||
start: (i32, i32),
|
||||
lo: (i32, i32),
|
||||
hi: (i32, i32),
|
||||
cells: HashMap<(i32, i32), Info>,
|
||||
bad: HashSet<(i32, i32)>,
|
||||
g: HashMap<(i32, i32), f32>,
|
||||
came: HashMap<(i32, i32), (i32, i32)>,
|
||||
open: BinaryHeap<Open>,
|
||||
/// Edges waiting for a cell the game has not answered.
|
||||
parked: Vec<((i32, i32), (i32, i32))>,
|
||||
retry_at: u64,
|
||||
stuck_since: Option<u64>,
|
||||
/// The cell opened that is nearest the destination.
|
||||
near: ((i32, i32), f64),
|
||||
expanded: usize,
|
||||
goal_checked: bool,
|
||||
phase: Phase,
|
||||
pts: Vec<DVec3>,
|
||||
next: usize,
|
||||
pub(super) partial: bool,
|
||||
replans: u32,
|
||||
ahead_wait: u32,
|
||||
began: u64,
|
||||
pub(super) told: bool,
|
||||
failed: Option<Why>,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub(super) fn new(pos: DVec3, dest: DVec3, slack: f64, slope: f64, now: u64) -> Self {
|
||||
let dist = horizontal(dest - pos).length();
|
||||
let cell = if dist <= 400.0 { 4 } else if dist <= 900.0 { 6 } else { 8 };
|
||||
let mut r = Self {
|
||||
dest,
|
||||
slack,
|
||||
cell,
|
||||
slope,
|
||||
start: (0, 0),
|
||||
lo: (0, 0),
|
||||
hi: (0, 0),
|
||||
cells: HashMap::new(),
|
||||
bad: HashSet::new(),
|
||||
g: HashMap::new(),
|
||||
came: HashMap::new(),
|
||||
open: BinaryHeap::new(),
|
||||
parked: Vec::new(),
|
||||
retry_at: 0,
|
||||
stuck_since: None,
|
||||
near: ((0, 0), f64::MAX),
|
||||
expanded: 0,
|
||||
goal_checked: slack > 0.0,
|
||||
phase: Phase::Search,
|
||||
pts: Vec::new(),
|
||||
next: 0,
|
||||
partial: false,
|
||||
replans: 0,
|
||||
ahead_wait: 0,
|
||||
began: now,
|
||||
told: false,
|
||||
failed: None,
|
||||
};
|
||||
r.restart(pos);
|
||||
r
|
||||
}
|
||||
|
||||
fn cell_of(&self, p: DVec3) -> (i32, i32) {
|
||||
((p.x.floor() as i32).div_euclid(self.cell), (p.z.floor() as i32).div_euclid(self.cell))
|
||||
}
|
||||
fn centre(&self, c: (i32, i32)) -> DVec3 {
|
||||
let k = f64::from(self.cell);
|
||||
DVec3::new((f64::from(c.0) + 0.5) * k, 0.0, (f64::from(c.1) + 0.5) * k)
|
||||
}
|
||||
fn to_goal(&self, c: (i32, i32)) -> f64 {
|
||||
horizontal(self.centre(c) - self.dest).length()
|
||||
}
|
||||
fn is_goal(&self, c: (i32, i32)) -> bool {
|
||||
if self.slack > 0.0 { self.to_goal(c) <= self.slack } else { c == self.cell_of(self.dest) }
|
||||
}
|
||||
|
||||
/// Searches afresh from `pos`, keeping the cells found bad.
|
||||
fn restart(&mut self, pos: DVec3) {
|
||||
self.start = self.cell_of(pos);
|
||||
let goal = self.cell_of(self.dest);
|
||||
let margin = (((self.start.0 - goal.0).abs().max((self.start.1 - goal.1).abs()) / 2).max(16)).min(64);
|
||||
self.lo = (self.start.0.min(goal.0) - margin, self.start.1.min(goal.1) - margin);
|
||||
self.hi = (self.start.0.max(goal.0) + margin, self.start.1.max(goal.1) + margin);
|
||||
self.g.clear();
|
||||
self.came.clear();
|
||||
self.open.clear();
|
||||
self.parked.clear();
|
||||
self.pts.clear();
|
||||
self.next = 0;
|
||||
self.phase = Phase::Search;
|
||||
self.stuck_since = None;
|
||||
self.expanded = 0;
|
||||
self.near = (self.start, f64::MAX);
|
||||
self.partial = false;
|
||||
self.cells.insert(self.start, Info { h: pos.y as f32, ok: true });
|
||||
self.g.insert(self.start, 0.0);
|
||||
let h = self.heur(self.start);
|
||||
self.open.push(Open(h, self.start));
|
||||
}
|
||||
|
||||
fn heur(&self, c: (i32, i32)) -> f32 {
|
||||
(self.to_goal(c) * 1.1) as f32
|
||||
}
|
||||
|
||||
fn eval(&mut self, ground: &mut Ground, now: u64, c: (i32, i32)) -> Eval {
|
||||
if let Some(i) = self.cells.get(&c) {
|
||||
return if i.ok { Eval::Ok(i.h) } else { Eval::Bad };
|
||||
}
|
||||
if self.bad.contains(&c) {
|
||||
return Eval::Bad;
|
||||
}
|
||||
let p = self.centre(c);
|
||||
let (x, z) = (p.x.floor() as i32, p.z.floor() as i32);
|
||||
let verdict = match ground.column(x, z, 1024) {
|
||||
None => return Eval::Pending,
|
||||
Some(None) => Info { h: 0.0, ok: false },
|
||||
Some(Some(h)) => {
|
||||
// The height is the top of what blocks motion or holds a fluid: water has nothing solid under it.
|
||||
let top = DVec3::new(f64::from(x) + 0.5, h, f64::from(z) + 0.5);
|
||||
match ground.world.sweep(top + DVec3::Y * 0.5, top - DVec3::Y * 0.5, false, now) {
|
||||
world::Sweep::Hit { .. } => Info { h: h as f32, ok: true },
|
||||
world::Sweep::Clear => Info { h: h as f32, ok: false },
|
||||
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Eval::Pending,
|
||||
world::Sweep::Unknown { .. } => Info { h: h as f32, ok: false },
|
||||
}
|
||||
}
|
||||
};
|
||||
self.cells.insert(c, verdict);
|
||||
if verdict.ok { Eval::Ok(verdict.h) } else { Eval::Bad }
|
||||
}
|
||||
|
||||
/// Relaxes the edge from the opened cell `c` to `nb`; parks it when the land between isn't known yet.
|
||||
fn relax(&mut self, ground: &mut Ground, now: u64, c: (i32, i32), nb: (i32, i32)) {
|
||||
if nb.0 < self.lo.0 || nb.1 < self.lo.1 || nb.0 > self.hi.0 || nb.1 > self.hi.1 {
|
||||
return;
|
||||
}
|
||||
let (di, dj) = (nb.0 - c.0, nb.1 - c.1);
|
||||
let len = if di != 0 && dj != 0 { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
let dist = len * f64::from(self.cell);
|
||||
let Some(h0) = self.cells.get(&c).map(|i| f64::from(i.h)) else { return };
|
||||
let h1 = match self.eval(ground, now, nb) {
|
||||
Eval::Ok(h) => f64::from(h),
|
||||
Eval::Bad => return,
|
||||
Eval::Pending => return self.park(c, nb),
|
||||
};
|
||||
// A diagonal doesn't cut a corner of something in the way.
|
||||
if di != 0 && dj != 0 {
|
||||
for side in [(c.0 + di, c.1), (c.0, c.1 + dj)] {
|
||||
match self.eval(ground, now, side) {
|
||||
Eval::Ok(h) if (f64::from(h) - h0).abs() <= (self.slope * f64::from(self.cell)).max(STEP_UP) => {}
|
||||
Eval::Pending => return self.park(c, nb),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Between the centres: a wall one block thick or a ditch there is what the centres don't show.
|
||||
let (a, b) = (self.centre(c), self.centre(nb));
|
||||
let mid = (a + b) * 0.5;
|
||||
let hm = match ground.column(mid.x.floor() as i32, mid.z.floor() as i32, 1024) {
|
||||
None => return self.park(c, nb),
|
||||
Some(None) => return,
|
||||
Some(Some(h)) => h,
|
||||
};
|
||||
let half = dist * 0.5;
|
||||
let allow = |l: f64| (self.slope * l).max(STEP_UP);
|
||||
if (hm - h0).abs() > allow(half) || (h1 - hm).abs() > allow(half) || hm - h0 < -STEP_DOWN * (half / 2.0).max(1.0) {
|
||||
return;
|
||||
}
|
||||
let dh = (h1 - h0).abs();
|
||||
if dh > allow(dist) {
|
||||
return;
|
||||
}
|
||||
let cost = (dist + 2.0 * dh) as f32;
|
||||
let ng = self.g.get(&c).copied().unwrap_or(0.0) + cost;
|
||||
if self.g.get(&nb).is_none_or(|&g| ng < g) {
|
||||
self.g.insert(nb, ng);
|
||||
self.came.insert(nb, c);
|
||||
self.open.push(Open(ng + self.heur(nb), nb));
|
||||
}
|
||||
}
|
||||
|
||||
fn park(&mut self, c: (i32, i32), nb: (i32, i32)) {
|
||||
if self.parked.len() < 4096 {
|
||||
self.parked.push((c, nb));
|
||||
}
|
||||
}
|
||||
|
||||
/// Works on the search: at most `budget` cells.
|
||||
fn search(&mut self, ground: &mut Ground, now: u64, mut budget: usize) {
|
||||
if !self.goal_checked {
|
||||
match self.eval(ground, now, self.cell_of(self.dest)) {
|
||||
Eval::Bad => {
|
||||
self.failed = Some(Why::Goal);
|
||||
return;
|
||||
}
|
||||
Eval::Ok(_) => self.goal_checked = true,
|
||||
Eval::Pending => {}
|
||||
}
|
||||
}
|
||||
if now >= self.retry_at && !self.parked.is_empty() {
|
||||
self.retry_at = now + 6;
|
||||
let parked = std::mem::take(&mut self.parked);
|
||||
for (k, &(c, nb)) in parked.iter().enumerate() {
|
||||
// The game loads the chunk of the first few, if it loads on request.
|
||||
if k < 6 {
|
||||
let p = self.centre(nb);
|
||||
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
|
||||
}
|
||||
self.relax(ground, now, c, nb);
|
||||
}
|
||||
}
|
||||
let before = self.expanded;
|
||||
while budget > 0 {
|
||||
let Some(Open(f, c)) = self.open.pop() else { break };
|
||||
let g = self.g.get(&c).copied().unwrap_or(f32::INFINITY);
|
||||
if f > g + self.heur(c) + 1e-3 {
|
||||
continue;
|
||||
}
|
||||
budget -= 1;
|
||||
self.expanded += 1;
|
||||
if self.is_goal(c) {
|
||||
self.finish(c, false);
|
||||
return;
|
||||
}
|
||||
let d = self.to_goal(c);
|
||||
if d < self.near.1 {
|
||||
self.near = (c, d);
|
||||
}
|
||||
for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)] {
|
||||
self.relax(ground, now, c, (c.0 + di, c.1 + dj));
|
||||
}
|
||||
if self.expanded >= MAX_NODES {
|
||||
self.settle(now);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.expanded > before {
|
||||
self.stuck_since = None;
|
||||
}
|
||||
if self.open.is_empty() {
|
||||
if self.parked.is_empty() {
|
||||
let goal = self.cell_of(self.dest);
|
||||
self.failed = Some(if self.slack == 0.0 && !self.cells.get(&goal).is_none_or(|i| i.ok) { Why::Goal } else { Why::NoWay });
|
||||
} else {
|
||||
let since = *self.stuck_since.get_or_insert(now);
|
||||
if now.saturating_sub(since) > WAIT_MAP {
|
||||
self.settle(now);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.stuck_since = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The land stays unknown (or the search is too big): the way to the nearest cell it got to.
|
||||
fn settle(&mut self, _now: u64) {
|
||||
let (c, d) = self.near;
|
||||
let here = self.to_goal(self.start);
|
||||
if c == self.start || here - d < f64::from(self.cell) * 2.0 {
|
||||
self.failed = Some(Why::NoMap);
|
||||
} else {
|
||||
self.finish(c, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The way to cell `c`, as points with the straight stretches dropped.
|
||||
fn finish(&mut self, c: (i32, i32), partial: bool) {
|
||||
let mut cells = vec![c];
|
||||
let mut k = c;
|
||||
while let Some(&p) = self.came.get(&k) {
|
||||
cells.push(p);
|
||||
k = p;
|
||||
if cells.len() > 100_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cells.reverse();
|
||||
let mut pts: Vec<DVec3> = Vec::new();
|
||||
for w in 1..cells.len() {
|
||||
let dir = (cells[w].0 - cells[w - 1].0, cells[w].1 - cells[w - 1].1);
|
||||
let last = w + 1 == cells.len();
|
||||
let turns = !last && (cells[w + 1].0 - cells[w].0, cells[w + 1].1 - cells[w].1) != dir;
|
||||
if last || turns {
|
||||
pts.push(self.centre(cells[w]));
|
||||
}
|
||||
}
|
||||
let end = if partial || self.slack > 0.0 { self.centre(c) } else { self.dest };
|
||||
match pts.last_mut() {
|
||||
Some(l) => *l = end,
|
||||
None => pts.push(end),
|
||||
}
|
||||
self.pts = pts;
|
||||
self.next = 0;
|
||||
self.partial = partial;
|
||||
self.phase = Phase::Follow;
|
||||
}
|
||||
|
||||
/// The column ahead stopped the vehicle: mark it, and search again from here.
|
||||
pub(super) fn blocked_ahead(&mut self, at: DVec3, pos: DVec3) {
|
||||
let c = self.cell_of(at);
|
||||
if c != self.cell_of(pos) {
|
||||
self.bad.insert(c);
|
||||
self.cells.insert(c, Info { h: 0.0, ok: false });
|
||||
} else {
|
||||
// In the cell itself: the stretch ahead, one cell on.
|
||||
let d = horizontal(at - pos).normalize_or(DVec3::X) * f64::from(self.cell);
|
||||
let c = self.cell_of(pos + d);
|
||||
self.bad.insert(c);
|
||||
self.cells.insert(c, Info { h: 0.0, ok: false });
|
||||
}
|
||||
self.replans += 1;
|
||||
if self.replans > REPLANS {
|
||||
self.failed = Some(Why::Stuck);
|
||||
} else {
|
||||
self.restart(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks the vehicle has waited for the land ahead; true when it has waited too long.
|
||||
pub(super) fn wait_ahead(&mut self) -> bool {
|
||||
self.ahead_wait += 1;
|
||||
self.ahead_wait > WAIT_AHEAD
|
||||
}
|
||||
pub(super) fn ahead_ok(&mut self) {
|
||||
self.ahead_wait = 0;
|
||||
}
|
||||
pub(super) fn waited(&self, now: u64) -> u64 {
|
||||
now.saturating_sub(self.began)
|
||||
}
|
||||
|
||||
/// What the vehicle at `pos` does: works the search on its share of the tick, then follows the way.
|
||||
pub(super) fn drive(&mut self, ground: &mut Ground, now: u64, pos: DVec3, budget: usize) -> Drive {
|
||||
if self.phase == Phase::Search && self.failed.is_none() {
|
||||
self.search(ground, now, budget);
|
||||
}
|
||||
if let Some(why) = self.failed {
|
||||
return Drive::Fail(why);
|
||||
}
|
||||
if self.phase == Phase::Search {
|
||||
return Drive::Wait(Why::Map);
|
||||
}
|
||||
let reach = f64::from(self.cell) * 0.7;
|
||||
while self.next + 1 < self.pts.len() && horizontal(self.pts[self.next] - pos).length() < reach {
|
||||
self.next += 1;
|
||||
}
|
||||
Drive::Go(self.pts[self.next], self.next + 1 >= self.pts.len())
|
||||
}
|
||||
|
||||
pub(super) fn searching(&self) -> bool {
|
||||
self.phase == Phase::Search && self.failed.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The routes of the vehicles that drive, by system id, and the jobs a truck found out of reach.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Routes {
|
||||
pub map: HashMap<u32, Route>,
|
||||
/// (truck id, the job's id) and until when the truck leaves it alone.
|
||||
pub unreachable: HashMap<(u32, u32), u64>,
|
||||
}
|
||||
|
||||
impl Routes {
|
||||
pub(super) fn out_of_reach(&self, truck: u32, job: u32, now: u64) -> bool {
|
||||
self.unreachable.get(&(truck, job)).is_some_and(|&t| t > now)
|
||||
}
|
||||
}
|
||||
|
||||
impl SamKind {
|
||||
/// The steepest slope (rise a block) it drives: trucks the least.
|
||||
pub(super) fn climbs(self) -> f64 {
|
||||
match self.spec().family {
|
||||
Family::Truck => 0.5,
|
||||
_ => 0.6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The land as the game would tell it: the height map, and under it the blocks that are solid.
|
||||
struct Src<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32> {
|
||||
height: H,
|
||||
floor: B,
|
||||
}
|
||||
impl<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32> world::WorldSource for Src<H, B> {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
Some(y < (self.floor)(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
fn answer<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(ground: &mut Ground, src: &Src<H, B>, now: u64) {
|
||||
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (src.height)(x, z))).collect();
|
||||
ground.answer(columns, now);
|
||||
let sections = ground.world.take_wanted(now);
|
||||
ground.world.serve(src, sections, now);
|
||||
}
|
||||
|
||||
/// Drives a Patriot from the origin to `to` over land of the given heights (water: a height over a lower floor).
|
||||
fn march<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) {
|
||||
let src = Src { height, floor };
|
||||
let mut sim = Sim::new(3);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.5, 64.0, 0.5), 0.0));
|
||||
let mut out = Out::default();
|
||||
for _ in 0..3 {
|
||||
sim.tick += 1;
|
||||
answer(&mut ground, &src, sim.tick);
|
||||
}
|
||||
assert!(sim.order_move(0, to));
|
||||
let mut used = 0;
|
||||
let mut worst = 0usize;
|
||||
for _ in 0..ticks {
|
||||
sim.tick += 1;
|
||||
sim.step_moves(&mut ground, &mut out);
|
||||
answer(&mut ground, &src, sim.tick);
|
||||
used += 1;
|
||||
let p = sim.sams[0].pos;
|
||||
let (x, z) = (p.x.floor() as i32, p.z.floor() as i32);
|
||||
assert!(((src.height)(x, z) as f64) <= p.y + 2.5, "inside the land at {p:?}");
|
||||
assert!((src.floor)(x, z) >= (src.height)(x, z), "drove onto water at {p:?}");
|
||||
worst = worst.max(sim.routes.map.values().filter(|r| r.searching()).count());
|
||||
if !sim.sams[0].moving() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = worst;
|
||||
(sim, out, used)
|
||||
}
|
||||
|
||||
// A wall across the way, with a gap far to one side: the vehicle goes round and arrives.
|
||||
#[test]
|
||||
fn a_wall_with_a_gap_is_driven_round() {
|
||||
let wall = |x: i32, z: i32| if (40..44).contains(&x) && (-60..=40).contains(&z) { 90 } else { 64 };
|
||||
let (sim, _, _) = march(wall, wall, DVec3::new(120.0, 64.0, 0.5), 6000);
|
||||
let s = &sim.sams[0];
|
||||
assert!(!s.moving() && s.pos.distance(DVec3::new(120.0, 64.0, 0.5)) < 3.0, "ended at {:?} {:?}", s.pos, s.state);
|
||||
}
|
||||
|
||||
// A closed pocket: refused with a reason, no teleport and no drive into the wall.
|
||||
#[test]
|
||||
fn an_unreachable_place_is_refused() {
|
||||
let walls = |x: i32, z: i32| if (60..64).contains(&x) && (-300..300).contains(&z) { 100 } else { 64 };
|
||||
let (sim, out, _) = march(walls, walls, DVec3::new(120.0, 64.0, 0.5), 6000);
|
||||
let s = &sim.sams[0];
|
||||
assert!(!s.moving() && s.pos.x < 62.0, "{:?}", s.pos);
|
||||
assert!(out.messages.iter().any(|m| m.contains("прохода нет") || m.contains("перекрыт") || m.contains("карта")), "{:?}", out.messages);
|
||||
}
|
||||
|
||||
// A lake (height 64 over a floor of 50) is no road: it is gone round, and a destination in it is refused.
|
||||
#[test]
|
||||
fn a_lake_is_not_a_road() {
|
||||
let floor = |x: i32, z: i32| if (50..90).contains(&x) && z.abs() < 40 { 50 } else { 64 };
|
||||
let (sim, _, _) = march(|_, _| 64, floor, DVec3::new(130.0, 64.0, 0.5), 6000);
|
||||
let s = &sim.sams[0];
|
||||
assert!(!s.moving() && s.pos.distance(DVec3::new(130.0, 64.0, 0.5)) < 3.0, "{:?}", s.pos);
|
||||
let (sim, out, _) = march(|_, _| 64, floor, DVec3::new(70.0, 64.0, 0.5), 3000);
|
||||
assert!(!sim.sams[0].moving() && sim.sams[0].pos.x < 10.0, "{:?}", sim.sams[0].pos);
|
||||
assert!(out.messages.iter().any(|m| m.contains("не проехать")), "{:?}", out.messages);
|
||||
}
|
||||
|
||||
// Open ground: straight on, arrives.
|
||||
#[test]
|
||||
fn open_ground_is_driven_straight() {
|
||||
let (sim, _, _) = march(|_, _| 64, |_, _| 64, DVec3::new(100.0, 64.0, 30.5), 1500);
|
||||
let s = &sim.sams[0];
|
||||
assert!(!s.moving() && s.pos.distance(DVec3::new(100.0, 64.0, 30.5)) < 3.0, "{:?}", s.pos);
|
||||
}
|
||||
}
|
||||
@@ -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<f64> {
|
||||
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<u32>) -> Option<LowSensor> {
|
||||
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<LowSensor> {
|
||||
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<f32> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ impl SiteClass {
|
||||
/// Ticks between two launches from one site.
|
||||
pub fn cadence(self) -> u64 {
|
||||
match self {
|
||||
SiteClass::Ramp => 3,
|
||||
SiteClass::Launcher => 8,
|
||||
SiteClass::Battery => 20,
|
||||
SiteClass::Ramp => 15,
|
||||
SiteClass::Launcher => 25,
|
||||
SiteClass::Battery => 50,
|
||||
}
|
||||
}
|
||||
fn slots(self) -> u32 {
|
||||
@@ -474,7 +474,17 @@ impl Sim {
|
||||
/// many. (The counter-strike, #101, uses this.)
|
||||
pub fn destroy_site(&mut self, id: u32) -> u32 {
|
||||
// An off-map site is nowhere to be found: nothing to destroy.
|
||||
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id && !s.offmap) else { return 0 };
|
||||
self.end_site(id, false)
|
||||
}
|
||||
|
||||
/// Ends a site's launches wherever it is, off the map too (a strike that would have hit it): the same as
|
||||
/// destroying it, without anything to draw.
|
||||
pub fn halt_site(&mut self, id: u32) -> u32 {
|
||||
self.end_site(id, true)
|
||||
}
|
||||
|
||||
fn end_site(&mut self, id: u32, offmap: bool) -> u32 {
|
||||
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id && (offmap || !s.offmap)) else { return 0 };
|
||||
s.alive = false;
|
||||
s.stock = 0;
|
||||
let ids: Vec<u32> = self.sites.jobs.iter().filter(|j| j.1 == id).map(|j| j.0).collect();
|
||||
@@ -795,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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<f64> {
|
||||
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> (Option<f64>, bool) {
|
||||
let mut need: Option<f64> = 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
|
||||
@@ -339,7 +354,7 @@ pub(super) const VERDICT_AGE: u64 = 20;
|
||||
|
||||
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
|
||||
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3) -> (DVec3, bool) {
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
|
||||
let spec = kind.spec();
|
||||
let want = (aim - pos).normalize_or(DVec3::Y);
|
||||
let flat = horizontal(want);
|
||||
@@ -357,10 +372,12 @@ pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f
|
||||
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
|
||||
let mut fail = false;
|
||||
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
|
||||
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
|
||||
// blocks that are really there, not before what the height map says is higher than the missile.
|
||||
fail = (1..=4).any(|k| {
|
||||
let p = pos + raised * speed * f64::from(k);
|
||||
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
|
||||
});
|
||||
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
|
||||
}
|
||||
(raised, fail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Flights and the land. Strike drones follow the terrain with a look-ahead worth several seconds of flight
|
||||
//! (what they can still climb is a question of their speed and turn rate), dive only down a clear line, and
|
||||
//! interceptors keep a safety height over the land ahead, loft over a ridge when the line is blocked, and
|
||||
//! blow up high when there is no way over. The launch predicates (`why`) ask the same questions of a
|
||||
//! shot before it is fired, so a system doesn't spend missiles on targets behind a hill.
|
||||
//!
|
||||
//! The land comes from `Ground::land`: heights sampled on a coarse lattice, so many flights share columns and
|
||||
//! the questions to the game stay few; what the heights miss (trees, buildings, overhangs) is caught by a
|
||||
//! probe through the block cache.
|
||||
use super::*;
|
||||
|
||||
/// Land heights are sampled at the centres of cells this wide.
|
||||
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;
|
||||
|
||||
/// What the game has said of the land at a place.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(super) enum Rise {
|
||||
/// Not yet: asked.
|
||||
Pending,
|
||||
/// There is no land to tell of (no chunk).
|
||||
Void,
|
||||
/// One above the highest solid block.
|
||||
At(f64),
|
||||
}
|
||||
|
||||
impl Ground {
|
||||
/// One above the highest solid block at the lattice cell holding `p`, if known; asked for when it isn't.
|
||||
/// Like `at` it asks again for a column that is old or was reported changed (#64), and it leaves room for the questions that matter more.
|
||||
pub fn land(&mut self, p: DVec3) -> Option<f64> {
|
||||
match self.rise(p) {
|
||||
Rise::At(g) => Some(g),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a planner: None while the game has not answered, Some(None) where it has no land to
|
||||
/// tell of, else Some(Some(height)).
|
||||
pub fn land_state(&mut self, p: DVec3) -> Option<Option<f64>> {
|
||||
match self.rise(p) {
|
||||
Rise::Pending => None,
|
||||
Rise::Void => Some(None),
|
||||
Rise::At(g) => Some(Some(g)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same, saying whether the game has not answered yet or has no land there to tell of.
|
||||
pub(super) fn rise(&mut self, p: DVec3) -> Rise {
|
||||
let key = (
|
||||
(p.x.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
|
||||
(p.z.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
|
||||
);
|
||||
match self.known.get(&key) {
|
||||
Some(&(y, when)) => {
|
||||
// Old or reported changed: still used, asked again with the questions to spare.
|
||||
if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void }
|
||||
}
|
||||
None => {
|
||||
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
Rise::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The land's height at a column for the raid planner: `None` while the game hasn't answered, `Some(None)`
|
||||
/// where it has no land to tell of. Asks, up to `cap` questions waiting.
|
||||
pub(super) fn column(&mut self, x: i32, z: i32, cap: usize) -> Option<Option<f64>> {
|
||||
match self.known.get(&(x, z)) {
|
||||
Some(&(y, when)) => {
|
||||
if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
Some((y > -64).then_some(f64::from(y)))
|
||||
}
|
||||
None => {
|
||||
// A chunk the game is loading is asked about again in a few ticks, not every one.
|
||||
let loading = self.loading.get(&(x, z)).is_some_and(|&t| self.now.saturating_sub(t) < LOAD_RECHECK);
|
||||
if !loading && self.wanted.len() < cap && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flight can do about a rise ahead.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Climb {
|
||||
/// Radius of its tightest pull-up (blocks).
|
||||
pub radius: f64,
|
||||
/// The steepest climb it holds (radians).
|
||||
pub pitch: f64,
|
||||
}
|
||||
|
||||
impl Climb {
|
||||
/// How much height it can have gained by the time it has gone `s` blocks on, starting level.
|
||||
pub fn gain(&self, s: f64) -> f64 {
|
||||
let arc = self.radius * self.pitch.sin();
|
||||
if s <= arc {
|
||||
self.radius - (self.radius * self.radius - s * s).max(0.0).sqrt()
|
||||
} else {
|
||||
self.radius * (1.0 - self.pitch.cos()) + (s - arc) * self.pitch.tan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DroneKind {
|
||||
/// The steepest climb it holds in cruise (radians): cruise missiles are the shallowest.
|
||||
pub(super) fn climb_pitch(self) -> f64 {
|
||||
match self {
|
||||
DroneKind::Kh101 | DroneKind::Kalibr => 0.45,
|
||||
DroneKind::Lancet => 0.7,
|
||||
DroneKind::Geran3 | DroneKind::Kh22 => 0.6,
|
||||
_ => 0.5,
|
||||
}
|
||||
}
|
||||
pub(super) fn climb(self) -> Climb {
|
||||
let s = self.spec();
|
||||
Climb { radius: (s.speed / s.turn.max(0.01) * 1.15).max(12.0), pitch: self.climb_pitch() }
|
||||
}
|
||||
/// Blocks it looks ahead: eight seconds of flight, at least a pull-up and a climb over a hill.
|
||||
pub(super) fn look_len(self) -> f64 {
|
||||
let s = self.spec();
|
||||
(s.speed * 160.0).clamp(160.0, 900.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A flight's new heading: it turns in yaw towards `heading` and in pitch towards a climb of `slope`, together
|
||||
/// at most `rate` radians a tick, so a turn on to a new course doesn't dip or climb by itself (a great-circle
|
||||
/// turn between nearly opposite headings can go either way). With `climb` set the pitch comes first and the
|
||||
/// yaw gets what is left of the rate.
|
||||
pub(super) fn fly_towards(old: DVec3, heading: DVec3, slope: f64, rate: f64, climb: bool) -> DVec3 {
|
||||
let (old_yaw, old_pitch) = (old.x.atan2(old.z), old.y.clamp(-1.0, 1.0).asin());
|
||||
let want_yaw = heading.x.atan2(heading.z);
|
||||
let mut yaw = angle_diff(old_yaw, want_yaw);
|
||||
let mut pitch = slope.atan() - old_pitch;
|
||||
if climb {
|
||||
pitch = pitch.clamp(-rate, rate);
|
||||
yaw = yaw.clamp(-(rate * rate - pitch * pitch).max(0.0).sqrt(), (rate * rate - pitch * pitch).max(0.0).sqrt());
|
||||
} else {
|
||||
let total = yaw.hypot(pitch);
|
||||
if total > rate {
|
||||
yaw *= rate / total;
|
||||
pitch *= rate / total;
|
||||
}
|
||||
}
|
||||
let (yaw, pitch) = (old_yaw + yaw, old_pitch + pitch);
|
||||
DVec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos())
|
||||
}
|
||||
|
||||
/// How far over the land a dive's line passes.
|
||||
pub(super) const DIVE_MARGIN: f64 = 8.0;
|
||||
|
||||
/// `v` with its pitch held between `lo` and `hi` (radians); `level` is the way it faces if it points straight up or down.
|
||||
pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
|
||||
let pitch = v.y.clamp(-1.0, 1.0).asin();
|
||||
if (lo..=hi).contains(&pitch) {
|
||||
return v;
|
||||
}
|
||||
let pitch = pitch.clamp(lo, hi);
|
||||
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,
|
||||
720.0,
|
||||
];
|
||||
|
||||
/// 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<f64>, bool) {
|
||||
let mut need: Option<f64> = 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 {
|
||||
break;
|
||||
}
|
||||
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, blind)
|
||||
}
|
||||
|
||||
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
|
||||
/// land (the last stretch before the aim is the target's own business). `None` when the land isn't known.
|
||||
pub(super) fn dive_height(ground: &mut Ground, pos: DVec3, target: DVec3, margin: f64) -> Option<f64> {
|
||||
let flat = horizontal(target - pos);
|
||||
let total = flat.length();
|
||||
if total < 20.0 {
|
||||
return None;
|
||||
}
|
||||
let dir = flat / total;
|
||||
let mut need: Option<f64> = None;
|
||||
let n = ((total - 14.0) / 12.0).clamp(1.0, 40.0) as i32;
|
||||
for k in 0..=n {
|
||||
let s = (total - 14.0) * f64::from(k) / f64::from(n);
|
||||
if let Some(g) = ground.land(pos + dir * s) {
|
||||
let f = s / total;
|
||||
let y = (g + margin - target.y * f) / (1.0 - f);
|
||||
need = Some(need.map_or(y, |m: f64| m.max(y)));
|
||||
}
|
||||
}
|
||||
need
|
||||
}
|
||||
|
||||
/// The slope (rise a block) a flight at `from` must hold to clear the land by `margin` on its way towards
|
||||
/// `to`, with a pull-up of `radius` for the climb to begin early, ignoring the last `skip` blocks before `to`.
|
||||
/// Returns the slope and how far away the land that asks for it is; `None` when nothing is in the way or known.
|
||||
pub(super) fn rope(ground: &mut Ground, from: DVec3, to: DVec3, margin: f64, radius: f64, (near, reach, skip): (f64, f64, f64), pending: &mut bool) -> Option<(f64, f64)> {
|
||||
let flat = horizontal(to - from);
|
||||
let total = flat.length();
|
||||
if total < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let dir = flat / total;
|
||||
let end = (total - skip).min(reach);
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
let mut s = near;
|
||||
while s <= end {
|
||||
match ground.rise(from + dir * s) {
|
||||
Rise::At(g) => {
|
||||
let slope = (g + margin - from.y) / (s - radius * 0.6).max(6.0);
|
||||
if best.is_none_or(|b| slope > b.0) {
|
||||
best = Some((slope, s));
|
||||
}
|
||||
}
|
||||
Rise::Pending => *pending = true,
|
||||
Rise::Void => {}
|
||||
}
|
||||
s += if s < 64.0 { 8.0 } else { 16.0 };
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- interceptors
|
||||
|
||||
impl MissileKind {
|
||||
/// The steepest climb it flies to get over land (radians): long-range missiles loft, short-range ones don't.
|
||||
pub(super) fn loft(self) -> f64 {
|
||||
match self {
|
||||
MissileKind::M77n6 => 1.25,
|
||||
MissileKind::M48n6 => 1.1,
|
||||
MissileKind::Pac3 | MissileKind::IrisT | MissileKind::Amraam => 0.9,
|
||||
MissileKind::M9m317 => 0.8,
|
||||
MissileKind::P1sun => 0.5,
|
||||
MissileKind::M57e6 | MissileKind::M9m338 => 0.3,
|
||||
}
|
||||
}
|
||||
/// Whether it lofts over a ridge to a target behind it.
|
||||
pub(super) fn lofts(self) -> bool {
|
||||
self.loft() >= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// How high above the land an interceptor keeps.
|
||||
pub(super) const MISSILE_MARGIN: f64 = 8.0;
|
||||
|
||||
/// What keeps an interceptor from being launched at a target for the land in the way.
|
||||
pub(super) const MASKED: u8 = 1;
|
||||
pub(super) const PATH_BLOCKED: u8 = 2;
|
||||
/// The land under the line of fire has been asked for and not answered: the shot waits a tick or two.
|
||||
pub(super) const WAIT: u8 = 4;
|
||||
/// Ticks a shot waits for the land to be answered before it is judged on what is known.
|
||||
pub(super) const WAIT_TICKS: u64 = 30;
|
||||
|
||||
/// Whether a shot from `muzzle` (launcher at `antenna`) at a target forecast at `est` moving at `vel` may be
|
||||
/// fired for the land: the intercept point must not be behind a hill from the launcher, unless the missile
|
||||
/// lofts, and the path the missile would fly must be clear.
|
||||
pub(super) fn launch_blocks(ground: &mut Ground, kind: MissileKind, muzzle: DVec3, antenna: DVec3, est: DVec3, vel: DVec3) -> u8 {
|
||||
let spec = kind.spec();
|
||||
// Where it meets the target: the forecast carried on for the flight time.
|
||||
let mut at = est;
|
||||
for _ in 0..2 {
|
||||
let tgo = muzzle.distance(at) / (spec.speed * 0.8);
|
||||
at = est + vel * tgo;
|
||||
}
|
||||
let flat = horizontal(at - antenna);
|
||||
let total = flat.length();
|
||||
if total < 40.0 {
|
||||
return 0;
|
||||
}
|
||||
let dir = flat / total;
|
||||
// The line of sight from the launcher: any rise of the land over the line to the intercept point masks it.
|
||||
let mut masked = false;
|
||||
let mut waiting = false;
|
||||
let mut s = 12.0;
|
||||
while s < total - 12.0 {
|
||||
match ground.rise(antenna + dir * s) {
|
||||
Rise::At(g) if g + 1.0 > antenna.y + (at.y - antenna.y) * s / total => {
|
||||
masked = true;
|
||||
break;
|
||||
}
|
||||
Rise::Pending => waiting = true,
|
||||
_ => {}
|
||||
}
|
||||
s += 16.0;
|
||||
}
|
||||
if !masked && waiting {
|
||||
return WAIT;
|
||||
}
|
||||
if !masked {
|
||||
return 0;
|
||||
}
|
||||
if !kind.lofts() {
|
||||
return MASKED;
|
||||
}
|
||||
// A lofted path: climbs at the slope the highest land in the way asks for, and comes down on the target.
|
||||
let radius = spec.speed / spec.turn.max(0.05);
|
||||
let mut pending = false;
|
||||
let path = rope(ground, muzzle, at, MISSILE_MARGIN, radius, (32.0, total, 24.0), &mut pending);
|
||||
match path {
|
||||
Some((slope, _)) if slope > kind.loft().tan() => PATH_BLOCKED,
|
||||
_ if pending => WAIT,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// What the land said of a shot at `target` when it was last judged, if lately.
|
||||
pub(super) fn terrain_verdict(&self, target: u32, now: u64) -> Option<u8> {
|
||||
self.terrain.get(&target).filter(|(when, _)| now.saturating_sub(*when) <= VERDICT_AGE).map(|(_, bits)| *bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks a launch verdict (the land in the way) is kept before the shot is judged again.
|
||||
pub(super) const VERDICT_AGE: u64 = 20;
|
||||
|
||||
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
|
||||
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
|
||||
let spec = kind.spec();
|
||||
let want = (aim - pos).normalize_or(DVec3::Y);
|
||||
let flat = horizontal(want);
|
||||
let flat_len = flat.length();
|
||||
let reach = (speed * 18.0).clamp(60.0, 320.0);
|
||||
let radius = speed / spec.turn.max(0.05);
|
||||
let Some((slope, at)) = rope(ground, pos, aim, MISSILE_MARGIN, radius, (8.0, reach, 20.0), &mut false) else { return (want, false) };
|
||||
let wanted = if flat_len > 1e-6 { want.y / flat_len } else { f64::INFINITY };
|
||||
if slope <= wanted {
|
||||
return (want, false);
|
||||
}
|
||||
let cap = kind.loft().tan();
|
||||
let dir = flat.normalize_or(DVec3::X);
|
||||
let raised = (dir + DVec3::Y * slope.min(cap)).normalize();
|
||||
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
|
||||
let mut fail = false;
|
||||
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
|
||||
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
|
||||
// blocks that are really there, not before what the height map says is higher than the missile.
|
||||
fail = (1..=4).any(|k| {
|
||||
let p = pos + raised * speed * f64::from(k);
|
||||
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
|
||||
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
|
||||
}
|
||||
(raised, fail)
|
||||
}
|
||||
@@ -141,6 +141,10 @@ pub(super) fn basis(sam: &Sam, load: &units::MissileLoad, track: &Track, d: &Dro
|
||||
let est = track.est(now);
|
||||
let reach = reach_blocks(load.min, load.max, k, sam.pos, est);
|
||||
if blocks & ROLE == 0 {
|
||||
// The doctrine (sector, protected objects, reserve) rules over the role's shots and the long ones; the last chance is past it.
|
||||
if sam.doctrine.area_blocks(sam.pos, track, now) | sam.doctrine.reserve_blocks(sam.missiles, d) != 0 {
|
||||
return None;
|
||||
}
|
||||
if reach == 0 {
|
||||
return Some(Basis::Role);
|
||||
}
|
||||
@@ -158,8 +162,8 @@ pub(super) fn reach_fit(max: f64, dist: f64) -> f64 {
|
||||
|
||||
/// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range
|
||||
/// (by the track's forecast, not the target's true place).
|
||||
pub(super) fn gun_candidate(roles: u8, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
allowed(roles, track, now, d) && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
|
||||
pub(super) fn gun_candidate(roles: u8, doc: &doctrine::Doctrine, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
allowed(roles, track, now, d) && doc.area_blocks(pos, track, now) == 0 && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
|
||||
}
|
||||
|
||||
/// How many systems have each drone as their missile target and as their gun target.
|
||||
@@ -316,7 +320,11 @@ impl Sim {
|
||||
self.drones
|
||||
.iter()
|
||||
.filter(|d| d.alive() && (sam.roles & d.kind.spec().class.bit() != 0 || last_chance(self.last_chance, sam.roles, d, claimed(d, &missile_on, sam.target))))
|
||||
.max_by(|a, b| a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
|
||||
.max_by(|a, b| {
|
||||
// What the doctrine lets the system shoot at comes first; then the most threatening.
|
||||
let area = |d: &Drone| sam.track(d.id).is_none_or(|tr| sam.doctrine.area_blocks(sam.pos, tr, self.tick) == 0);
|
||||
area(a).cmp(&area(b)).then(a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
|
||||
})
|
||||
});
|
||||
let Some(t) = subject else {
|
||||
v.push(check(false, format!("нет целей классов [{}]", roles_text(sam.roles))));
|
||||
@@ -348,6 +356,29 @@ impl Sim {
|
||||
format!("роль: {} не в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles))
|
||||
};
|
||||
v.push(check(blocks & ROLE == 0 || chance, role_text));
|
||||
// The doctrine: which of its settings let the shot through or stopped it. The last chance is past them.
|
||||
let doc = &sam.doctrine;
|
||||
let area = track.map_or(0, |tr| doc.area_blocks(sam.pos, tr, now));
|
||||
let held_back = if spec.missile.is_some() { doc.reserve_blocks(sam.missiles, t) } else { 0 };
|
||||
if !doc.is_default() {
|
||||
v.push(check(true, format!("доктрина: {}", doc.text(&self.targets))));
|
||||
}
|
||||
if area & doctrine::OUT_SECTOR != 0 {
|
||||
let bearing = heading_of(est - sam.pos).to_degrees().rem_euclid(360.0);
|
||||
v.push(check(chance, format!("вне сектора {}: цель на {bearing:.0}°{}", doc.sector_text(), if chance { " — последний шанс сильнее" } else { "" })));
|
||||
} else if doc.sector.is_some() {
|
||||
v.push(check(true, format!("в секторе {}", doc.sector_text())));
|
||||
}
|
||||
if area & doctrine::NO_THREAT != 0 {
|
||||
v.push(check(chance, format!("не угрожает охраняемым объектам ({}){}", doc.guards.iter().map(|g| doctrine::object_name(&self.targets, *g)).collect::<Vec<_>>().join(", "), if chance { " — последний шанс сильнее" } else { "" })));
|
||||
} else if let Some(g) = track.and_then(|tr| doc.threatens(tr, now)) {
|
||||
v.push(check(true, format!("прикрывает {}: цель идёт на него", doctrine::object_name(&self.targets, doc.guards[g]))));
|
||||
}
|
||||
if held_back != 0 {
|
||||
v.push(check(chance, format!("сохраняет резерв {}: ракет {}, цель не срочная{}", doc.reserve, sam.missiles, if chance { " — последний шанс сильнее" } else { "" })));
|
||||
} else if doc.reserve > 0 && sam.missiles <= doc.reserve {
|
||||
v.push(check(true, format!("резерв {} открыт: цель срочная (до удара {:.0} с)", doc.reserve, arrival(t) / 20.0)));
|
||||
}
|
||||
let detect = match (track, sam.own.contains(&t.id)) {
|
||||
(Some(tr), own) => {
|
||||
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
|
||||
@@ -398,7 +429,7 @@ impl Sim {
|
||||
let radars: Vec<&Sam> = self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect();
|
||||
let cued = sam.kind.far_shooter() && radar_cue(sam, &radars, t.id, now);
|
||||
let role_in_reach = self.drones.iter().any(|o| {
|
||||
sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && claimed(o, &missile_on, sam.target) < needed(o))
|
||||
sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && sam.doctrine.area_blocks(sam.pos, tr, now) | sam.doctrine.reserve_blocks(sam.missiles, o) == 0 && claimed(o, &missile_on, sam.target) < sam.doctrine.needed(o))
|
||||
});
|
||||
let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0;
|
||||
let far_ok = far && track.is_some_and(|tr| basis(sam, load, tr, t, now, 0, 1.0, Opts { last_chance: self.last_chance, far_shots: self.far_shots }, cued, role_in_reach) == Some(Basis::FarShot));
|
||||
@@ -419,8 +450,8 @@ impl Sim {
|
||||
v.push(check(far_ok, if far_ok { format!("дальний пуск по РЛС: цель на {} дальше обычных {}", crate::panel::distance(dist), crate::panel::distance(load.max)) } else { format!("дальний пуск по РЛС не разрешён: {}", no.join(", ")) }));
|
||||
}
|
||||
let free = first_claim;
|
||||
let need = needed(t);
|
||||
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need}{}", if free < need { "" } else { " — другие уже на ней" })));
|
||||
let need = sam.doctrine.needed(t);
|
||||
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need} (залп {}){}", doc.salvo.name(), if free < need { "" } else { " — другие уже на ней" })));
|
||||
let mb = missile_blocks(sam);
|
||||
let mut why = Vec::new();
|
||||
if mb & COOLDOWN != 0 {
|
||||
@@ -451,12 +482,13 @@ impl Sim {
|
||||
},
|
||||
));
|
||||
}
|
||||
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && (reach == 0 || far_ok);
|
||||
let doc_ok = chance || area | held_back == 0;
|
||||
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && doc_ok && (reach == 0 || far_ok);
|
||||
fires |= grounds && free < need && mb == 0 && aligned && land == 0;
|
||||
}
|
||||
if let Some(gun) = &spec.gun {
|
||||
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
|
||||
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, tr, now, gun.track, sam.pos, t));
|
||||
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, &sam.doctrine, tr, now, gun.track, sam.pos, t));
|
||||
v.push(check(
|
||||
cand,
|
||||
format!(
|
||||
|
||||
+61
-5
@@ -13,9 +13,9 @@
|
||||
|
||||
## Статус
|
||||
|
||||
**Стадия 1 (прямой звук) и стадия 2 (отводы эха) сделаны**, включается `HORIZON_ACOUSTICS=1` (по умолчанию выключено,
|
||||
звук как был). Стадия 3 (реверб) не начата. Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
|
||||
GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acoustics/spr_bounce.rs`; остальное (движок `acoustics.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs`) наше.
|
||||
**Стадии 1 (прямой звук), 2 (отводы эха) и 3 (реверб) сделаны**, включается кнопкой мода или `HORIZON_ACOUSTICS=1` (по умолчанию выключено,
|
||||
звук как был). Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
|
||||
GPL-файлы три: `acoustics/spr_occlusion.rs`, (стадия 2) `acoustics/spr_bounce.rs` и (стадия 3) `acoustics/spr_reverb.rs`; остальное (движок `acoustics.rs`, `acoustics/room.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs` и `audio/reverb.rs`) наше.
|
||||
|
||||
Что вышло и чем отличается от плана выше:
|
||||
|
||||
@@ -91,7 +91,61 @@ GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acousti
|
||||
|
||||
Не сделано / не проверено: запуск в игре и экспорт видео с настоящей сценой (склейка `game/acoustics.rs` — таблица состояний,
|
||||
снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается
|
||||
ячейкой); потолок памяти снимка. Стадия 3 (шины реверба) не начата.
|
||||
ячейкой); потолок памяти снимка.
|
||||
|
||||
### Стадия 3: реверб (сделано)
|
||||
|
||||
Что игрок получает: под крышей и в тоннеле звук звенит иначе, чем в поле; в чистом поле реверба нет. Код: `acoustics/room.rs`
|
||||
(наш: замер пространства, уровень и спад, посылки), `acoustics/spr_reverb.rs` (GPL: четыре слота SPR, `0.7*0.85`, затухание посылок
|
||||
с расстоянием), `audio/reverb.rs` (наш: DSP общей шины), посылки голосов и шина в `audio.rs` (`Channel.send`, `Mixer.reverb`),
|
||||
склейка в `acoustics.rs` (`take_sends`).
|
||||
|
||||
- Замер пространства (`room::measure`): 48 лучей по сфере Фибоначчи, берутся те, что идут вбок и вверх (пол комнаты не делает), длина 64 блока
|
||||
(столько видит и SPR). Вес попадания `1 - (d/64)^2`. Из них: **замкнутость** (сумма весов / число лучей, 0..1), **размер** (средняя
|
||||
дальность стен) и **отражательность** (средняя по таблице SPR). Слушатель замеряется по своей клетке 1x1x1 (кеш 64, сброс по секциям в
|
||||
кубе ±65 и по эпохе, на рабочем потоке; пока новый замер в пути, остаётся прежний); у звуков с эхом замеряется ещё и пространство
|
||||
источника (в той же трассе эха, свой потолок 60 тыс. клеток). Пространство звука = слушатель 60% + источник 40% (без источника: слушатель).
|
||||
- Уровень и спад (`wet_and_decay`): `wet = smoothstep(0.03, 0.4, замкнутость)`; `спад = замкнутость^2 * (0.4 + 0.1*min(размер,30)) *
|
||||
(0.6 + 0.8*отражательность)`, в пределах 0.15-4.14 с (слоты SPR). Камень (1.5) звенит дольше шерсти (0.1). Ущелье с открытым верхом
|
||||
почти не даёт реверба (его ответ - эхо стадии 2); комната и тоннель - да.
|
||||
- Посылки: спад ложится между двумя соседними слотами SPR (0.15 / 0.55 / 1.68 / 4.14 с, по логарифму) равномерной по мощности
|
||||
перекрёстной кривой (квадрат положения у длинного, иначе по Шрёдеру читается только он), каждый слот выравнен по своему `gain` из
|
||||
таблицы SPR: громкость реверба не растёт с его длиной. Посылка голоса умножается на `distance_fade` SPR (`1 - d/(1.5*дальность)`).
|
||||
- Шина (`audio/reverb.rs`): 4 слота, в каждом пред-задержка (late delay SPR), 2 allpass, 4 гребёнки с lowpass в петле (коэффициент из
|
||||
`decay_hf_ratio` SPR на 5 кГц). Левое ухо берёт гребёнки 0 и 2, правое 1 и 3 (разные хвосты), моно = среднее всех четырёх. Голос
|
||||
кладёт в шину сигнал после стены (окклюзии) и до панорамы; посылки голоса плавно (постоянная 0.3 с) идут к цели, так что вход в тоннель
|
||||
и выход из него без щелчка. Шина считается и без голосов (хвост звучит после конца хлопка), слот без входа и хвоста не считается и
|
||||
обнуляется (без денормалей), шина сбрасывается, когда затихла.
|
||||
- Выключение (`set_acoustics(false)`): посылки идут к нулю за ~1-2 с, хвост доигрывает сам; `stop_all` (и `film_begin/film_end`) шину сбрасывают.
|
||||
Съёмка: замер синхронный (счёт клеток), шина считается без случайности и от часов не зависит - дорожка та же при каждом прогоне
|
||||
(`audio::tests::reverb_rendering_is_deterministic`).
|
||||
- Измерено (release, M-серия, буфер 512 кадров = 10.67 мс): шина с четырьмя занятыми слотами 0.017 мс; 100 голосов 0.119 мс без реверба ->
|
||||
+0.04 мс с реверб-посылками (25 или 100 голосов, около 0.4% буфера); замер пространства: комната 0.0075 мс (392 клетки), тоннель
|
||||
0.0037, ущелье 0.033 (2208), поле 0.035 мс (2315); всё на рабочем потоке. Счётчики в `status`: `acoustics.reverb` (замеры, попадания
|
||||
кеша, клетки, посылки, замкнутость и спад сейчас); в игре не снимались.
|
||||
- Проверка без слуха: `audio::tests::export_reverb_tracks` (настоящие `Acoustics`, посылки, `mix`, щелчок в комнате 24x10x24, тоннеле 5x4,
|
||||
ущелье, поле) + `python3 tools/acoustics_check.py --reverb-dir папка`. Порядок **поле < ущелье < тоннель < комната** (в плане было
|
||||
«ущелье длиннее всех»; открытый верх ущелья этого не даёт):
|
||||
|
||||
| сцена | спад пространства, с | RT60 по посылкам, с | RT60 измерено, с | хвост/щелчок, дБ |
|
||||
|---|---|---|---|---|
|
||||
| поле | 0.15 | - | хвоста нет | нет |
|
||||
| ущелье | 0.15 | 0.15 | 0.10 | -47.7 |
|
||||
| тоннель | 0.79 | 0.97 | 0.87 | -11.7 |
|
||||
| комната | 1.37 | 1.67 | 1.39 | -7.7 |
|
||||
|
||||
Колонка «по посылкам» - сумма экспонент слотов по Шрёдеру (`room::effective_decay`), измеренное лежит на 10-20% ниже (lowpass в петле
|
||||
укорачивает). Каждый слот отдельно (импульс в одну шину, `reverb::tests::each_slot_decays_at_its_time`) читается в 0.5-1.15 от
|
||||
номинала. Тесты: `--lib acoustics` (замеры комната/тоннель/ущелье/поле, порядок спадов, гладкость посылок, кеш, сброс, рабочий поток)
|
||||
и `--lib audio` (хвост после конца звука и сброс шины, нулевая посылка - ровно прежний вывод, без щелчка при смене и выключении,
|
||||
детерминизм, выключение и `film_begin`).
|
||||
|
||||
Не слушали: громкость реверба (`WET = 1.6` и уровни слотов не подстраивались на слух; в комнате хвост -8 дБ от щелчка по энергии), формулы
|
||||
замкнутости/спада (пороги 0.03-0.4, веса, перекрёстная кривая - выбраны по замерам сцен, не по слуху), звучание гребёнок (металличность на
|
||||
коротких слотах, пред-задержки), окраска левого и правого, 60/40 слушатель/источник, скорость скольжения посылок 0.3 с. Замкнутость
|
||||
по 24 лучам вверх-вбок грубая: узкая щель в крыше может не найтись, деревья и листва (окклюзия 0) пространства не делают. Реверб не
|
||||
получают относительные звуки (интерфейс, музыка) и звуки дальше 512 блоков; эхо-отводы в шину не идут. `gain_hf` слотов, `density`,
|
||||
отражения слота SPR не перенесены.
|
||||
|
||||
## 1. Как SPR идёт от источника к результату
|
||||
|
||||
@@ -395,7 +449,7 @@ GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acousti
|
||||
классов не даёт скачка; сброс при `film_begin/film_end`; лимитер не срабатывает на нормальном налёте.
|
||||
- Проверка без слуха: импульсный отклик шины из `film_mix` → RT60 по спаду (`tools/acoustics_check.py`) для четырёх
|
||||
классов сцены (поле, ущелье, комната, тоннель) — таблица в отчёте; спад должен упорядочиться
|
||||
поле < комната < тоннель < ущелье; налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
|
||||
поле < ущелье < тоннель < комната (у ущелья открытый верх); налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
|
||||
- Критерий: хвост после конца исходного хлопка длится ожидаемое RT60 и не продолжается после смены мира.
|
||||
|
||||
### Стадия 4 (по #96): движущиеся источники и интеграция с #95/cinema
|
||||
@@ -414,3 +468,5 @@ GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acousti
|
||||
- Коммит из #96 (`76f680eb…`) не найден в недавней истории `master`; разбор сделан по `453654ec`.
|
||||
- Мод-API `query-rays` с политикой `sound` для этого недостаточно (нет материала и полупрозрачных долей), поэтому
|
||||
акустика в клиенте; мод только сообщает события (#95).
|
||||
|
||||
Включается кнопкой в меню мода (ещё → «Акустика»), сохраняется с миром. `HORIZON_ACOUSTICS=1` задаёт только начальное значение.
|
||||
|
||||
@@ -88,6 +88,7 @@ python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "
|
||||
пересоберутся сами, если патчи изменились.
|
||||
- Windows: нужна видеокарта с Vulkan. Экспорт фильмов (`/airdef replay export`) требует `ffmpeg` в PATH или `HORIZON_FFMPEG`.
|
||||
- Шейдеры уровня шейдерпаков: `/airdef shader hd`. Для слабой видеокарты перед запуском: `$env:HORIZON_HD_SCALE="0.67"` (PowerShell).
|
||||
- Преграды и эхо для звука: меню мода → «Акустика».
|
||||
- Создай мир с читами (или включи их через «Открыть для сети»): мод выдаёт пульты и взрывы командами.
|
||||
- `/airdef give` — пульты, **N** — командный пункт (роли ПВО), **K** — монитор комплекса,
|
||||
`/airdef wave mix 20` — налёт. Подробнее — README.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Карта репозитория
|
||||
|
||||
Что здесь что: исходники, сгенерированное, архив. Порядок сборки и установки — в `README.md` и `docs/INSTALL.md`.
|
||||
|
||||
## Исходники (правим руками)
|
||||
|
||||
| Путь | Что это |
|
||||
|---|---|
|
||||
| `airdef-mod/src/`, `airdef-mod/shaders/` | Мод на Rust (WASM) для форка Horizon |
|
||||
| `airdef-mod/Cargo.toml`, `horizon-mod.toml` | Сборка мода |
|
||||
| `airdef-mod/tests/`, `airdef-mod/examples/` | Тесты и примеры |
|
||||
| `airdef-mod/NOTES-terrain-guidance.md` | Рабочие заметки по рельефу |
|
||||
| `horizon/`, `HORIZON_COMMIT`, `horizon-patches/` | Форк Horizon и патчи к нему |
|
||||
| `tools/airdef.py` | Сборка мода и установка в папку игры |
|
||||
| `tools/gen_models.py`, `tools/gen_tex.py`, `tools/gen_sounds.py` | Генераторы контента (см. ниже) |
|
||||
| `tools/acoustics_check.py`, `tools/hd_bench.py` | Проверочные скрипты |
|
||||
| `docs/` | Документация (этот файл, `INSTALL.md`, `ACOUSTICS.md`, `HD-RENDERER.md`) |
|
||||
| `install.sh`, `play.sh`, `dist/` | Установка и запуск |
|
||||
| `run-home/`, `run-home-bench/`, `run-home-smoke/` | Тестовые игровые папки, не часть мода |
|
||||
| `screenshots/` | Скриншоты |
|
||||
|
||||
## Сгенерировано (не правим руками)
|
||||
|
||||
`tools/gen_models.py` создаёт:
|
||||
|
||||
- `airdef-mod/assets/airdef/{blockstates,models,items,lang,textures}/`
|
||||
- `airdef-mod/data/airdef/horizon/`
|
||||
|
||||
Список этих файлов с sha256 лежит в `airdef-mod/generated-manifest.txt`. Генератор пишет во временный каталог, проверяет набор (ссылки моделей, blockstate и текстур; JSON; PNG; координаты элементов; парность lang) и только потом переносит файлы. При ошибке проверки ничего не записывается. Файл, который правили вручную или который занимает путь генерируемого, останавливает запуск (`КОНФЛИКТ`).
|
||||
|
||||
Команды:
|
||||
|
||||
- `python3 tools/gen_models.py` — перегенерировать.
|
||||
- `python3 tools/gen_models.py --check` — только сравнить, выход 1 при различиях.
|
||||
|
||||
`tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`.
|
||||
|
||||
## Архив: `legacy/`
|
||||
|
||||
Первая версия мода: датапак на командах (`legacy/airdef-datapack/`) и контент-мод (`legacy/airdef-content/`) с генератором `legacy/gen_content.py`. Ничего из текущей сборки на него не ссылается: ни скрипты в `tools/`, ни `install.sh`, ни `Cargo.toml`, ни исходники мода. Не собирается и не устанавливается. Оставлен как история. Не редактировать и не удалять без отдельного решения.
|
||||
|
||||
`legacy/airdef-content/data/` сейчас не отслеживается git и не входит в эту карту.
|
||||
@@ -1,7 +1,7 @@
|
||||
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||||
Subject: [PATCH 01/40] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/41] Mod API (AirDef fork): world models, client particles,
|
||||
sounds, ground heights
|
||||
|
||||
render.set-models draws block models with an interpolated pose per id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:39:45 +0300
|
||||
Subject: [PATCH 02/40] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/41] cargo-horizon-mod: report particles and sounds
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
||||
Subject: [PATCH 03/40] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/41] Mod API (AirDef fork): full-screen post pass for mods
|
||||
|
||||
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
|
||||
time, 256 vec4 of params, the world's colour and depth) and runs it after
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:09:30 +0300
|
||||
Subject: [PATCH 04/40] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/41] Mod models: draw far models as scaled impostors past
|
||||
the far plane, cull at 4096
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:31:00 +0300
|
||||
Subject: [PATCH 05/40] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/41] Mod cameras: render.set-views draws the world from
|
||||
other points into pictures that HUD view shapes show (API level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:08:21 +0300
|
||||
Subject: [PATCH 06/40] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/41] Mods: more fuel per tick and per event (AirDef plans
|
||||
raids)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:25:29 +0300
|
||||
Subject: [PATCH 07/40] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/41] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:15:11 +0300
|
||||
Subject: [PATCH 08/40] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/41] Mod cameras: distant terrain tiles, fog measured from
|
||||
the camera, no impostors while one is shown
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:25:45 +0300
|
||||
Subject: [PATCH 09/40] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/41] Mods: game.world-id, so mods keep state per world (API
|
||||
level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 12:01:08 +0300
|
||||
Subject: [PATCH 10/40] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/41] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
packing-only tool skips wasmtime
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:18:43 +0300
|
||||
Subject: [PATCH 11/40] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/41] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
|
||||
level 10)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:59:43 +0300
|
||||
Subject: [PATCH 12/40] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/41] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
world relit (sun by face angle and shadow, sky and block light through the
|
||||
lightmap), light shafts and halos, fog over it again, a light colour grade
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/40] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/41] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/40] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/41] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:43:36 +0300
|
||||
Subject: [PATCH 15/40] HD: GPU time per pass in the F3 timing list,
|
||||
Subject: [PATCH 15/41] HD: GPU time per pass in the F3 timing list,
|
||||
half-resolution GTAO with a depth-aware blur
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 16/40] HD: shadows draw only sun-facing quads and skip buried
|
||||
Subject: [PATCH 16/41] HD: shadows draw only sun-facing quads and skip buried
|
||||
sections, the far cascade every second frame; TAA (Halton jitter,
|
||||
reprojection by depth, YCoCg variance clipping) before bloom
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 17/40] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/41] Mods: model handles (render.model-id, model-generation,
|
||||
set-models-by-id; API level 11) so mods send no model names each tick;
|
||||
clear-post and set-lighting documented
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:19:57 +0300
|
||||
Subject: [PATCH 18/40] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
Subject: [PATCH 18/41] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
|
||||
pass); fix flickering shadows near the player (the near cascade lost its
|
||||
sections on frames the far one was held, the held far matrix ignored the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:47:52 +0300
|
||||
Subject: [PATCH 19/40] HD water: its own pass (colour, normal, depth), the
|
||||
Subject: [PATCH 19/41] HD water: its own pass (colour, normal, depth), the
|
||||
floor under it lit, a composite with world-space waves, refraction with depth
|
||||
absorption in the biome colour, screen-space reflections, Fresnel and a
|
||||
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:56:04 +0300
|
||||
Subject: [PATCH 20/40] HD water: no vanilla texture on the surface (the biome
|
||||
Subject: [PATCH 20/41] HD water: no vanilla texture on the surface (the biome
|
||||
colour only), a darker, deeper colour
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 18:38:19 +0300
|
||||
Subject: [PATCH 21/40] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
Subject: [PATCH 21/41] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
|
||||
and water reflections; warmer sunlight from the atmosphere's transmittance, a
|
||||
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 19:02:16 +0300
|
||||
Subject: [PATCH 22/40] HD water: a darker blue, a little clearer, ripples a
|
||||
Subject: [PATCH 22/41] HD water: a darker blue, a little clearer, ripples a
|
||||
little higher
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:41:48 +0300
|
||||
Subject: [PATCH 23/40] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
Subject: [PATCH 23/41] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
tint, blue hour; MIT), volumetric clouds with ground shadows instead of
|
||||
vanilla's in HD, volumetric fog with sun shafts and morning mist
|
||||
(HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:43:22 +0300
|
||||
Subject: [PATCH 24/40] Mods: world queries for simulations (render.query-rays,
|
||||
Subject: [PATCH 24/41] Mods: world queries for simulations (render.query-rays,
|
||||
query-sections, world-changes; API level 12): block-exact rays and section
|
||||
occupancy by collision, sight or sound, answered next tick within budgets,
|
||||
and a log of changed sections
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 22:56:15 +0300
|
||||
Subject: [PATCH 25/40] HD fixes: rain level from the world not from rain
|
||||
Subject: [PATCH 25/41] HD fixes: rain level from the world not from rain
|
||||
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
|
||||
without a depth heuristic (#35), air fog once over water (#37), translucent
|
||||
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:19:53 +0300
|
||||
Subject: [PATCH 26/40] HD split into modules (gfx/hd/: one Rust file and one
|
||||
Subject: [PATCH 26/41] HD split into modules (gfx/hd/: one Rust file and one
|
||||
WGSL file per pass, a shared prelude), contracts for colour, depth, alpha,
|
||||
time and coordinates written down, tests that the frame layout and bindings
|
||||
match between Rust and WGSL (#45)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:22:19 +0300
|
||||
Subject: [PATCH 27/40] HD clouds: noise scales that divide the camera's
|
||||
Subject: [PATCH 27/41] HD clouds: noise scales that divide the camera's
|
||||
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
|
||||
0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:17:11 +0300
|
||||
Subject: [PATCH 28/40] HD: volumetric smoke and missile trails with clear
|
||||
Subject: [PATCH 28/41] HD: volumetric smoke and missile trails with clear
|
||||
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
|
||||
speculars by material, wet surfaces and puddles in rain, wind in foliage
|
||||
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:26:09 +0300
|
||||
Subject: [PATCH 29/40] HD clouds composited by depth so terrain and vehicles
|
||||
Subject: [PATCH 29/41] HD clouds composited by depth so terrain and vehicles
|
||||
inside or behind the layer are hidden and those in front stay in front (#67);
|
||||
world-anchored noise everywhere (cloud detail by height above the layer, fog,
|
||||
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:44:45 +0300
|
||||
Subject: [PATCH 30/40] HD smoke: young trails drawn at least as wide as a grid
|
||||
Subject: [PATCH 30/41] HD smoke: young trails drawn at least as wide as a grid
|
||||
cell (density scaled to keep the mass), gentler bulges, so a trail is a
|
||||
smooth ribbon and not a chain of puffs
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:59:41 +0300
|
||||
Subject: [PATCH 31/40] Mods: render.set-main-view (API level 15) renders the
|
||||
Subject: [PATCH 31/41] Mods: render.set-main-view (API level 15) renders the
|
||||
screen's world from a mod camera with the full pipeline, so replays get HD
|
||||
and the post effects; small mod views get the post pass with their own camera
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 09:28:05 +0300
|
||||
Subject: [PATCH 32/40] Spot lights for mods (render.set-spot-lights, API level
|
||||
Subject: [PATCH 32/41] Spot lights for mods (render.set-spot-lights, API level
|
||||
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
|
||||
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
|
||||
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 14:30:39 +0300
|
||||
Subject: [PATCH 33/40] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
Subject: [PATCH 33/41] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
terrain-aware fog, far smoke cascade; replay world log and film clock;
|
||||
material overlays by sprite name
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:01:09 +0300
|
||||
Subject: [PATCH 34/40] HD: coloured sun through glass and leaves, rain ripples
|
||||
Subject: [PATCH 34/41] HD: coloured sun through glass and leaves, rain ripples
|
||||
on water, film motion blur and depth of field
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:19:32 +0300
|
||||
Subject: [PATCH 35/40] Film export: every frame in flight reaches the writer,
|
||||
Subject: [PATCH 35/41] Film export: every frame in flight reaches the writer,
|
||||
listener follows the main view, cloud wind on film frames, set-smoke reports
|
||||
real activity
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:43:11 +0300
|
||||
Subject: [PATCH 36/40] Mod command trees: command.set-tree and
|
||||
Subject: [PATCH 36/41] Mod command trees: command.set-tree and
|
||||
set-suggestions, typed parse, colouring and completion from one tree (API 19)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 16:10:23 +0300
|
||||
Subject: [PATCH 37/40] Persistent mod sound sources: start, update, stop with
|
||||
Subject: [PATCH 37/41] Persistent mod sound sources: start, update, stop with
|
||||
generations, click-free voices, loop sections, voice budget and virtual loops
|
||||
(API 20)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 19:07:37 +0300
|
||||
Subject: [PATCH 38/40] HD clouds: rays from a far point, so the view bob's eye
|
||||
Subject: [PATCH 38/41] HD clouds: rays from a far point, so the view bob's eye
|
||||
offset does not bend them
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 19f07ff4295766c7bee4bd1fbfc509b6a82d12d4 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 22:09:23 +0300
|
||||
Subject: [PATCH 39/40] Acoustics stage 1: direct-path occlusion ported from
|
||||
Subject: [PATCH 39/41] Acoustics stage 1: direct-path occlusion ported from
|
||||
Sound Physics Remastered 453654e (GPL-3.0, separate files), off by default;
|
||||
stereo output on surround devices; mixer sum in a fixed order (API 21)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 449edb87be5b5ddc61d0d2fbeef3a91316a97b62 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 23:44:14 +0300
|
||||
Subject: [PATCH 40/40] Mod chunk tickets through the server thread (API 22),
|
||||
Subject: [PATCH 40/41] Mod chunk tickets through the server thread (API 22),
|
||||
softer searchlights and light halos, acoustics stage 2: echo taps
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,15 @@
|
||||
python3 tools/acoustics_check.py --echo-dir папка # gorge/field/room и expected.txt из export_echo_tracks
|
||||
|
||||
Дорожки пишет `audio::tests::export_echo_tracks` (HORIZON_ACOUSTICS_OUT=папка). Коды выхода те же.
|
||||
Режим `--reverb-dir` (стадия 3): папка из `audio::tests::export_reverb_tracks` (щелчок в комнате, тоннеле, ущелье
|
||||
и поле; `expected_reverb.txt`: предсказанный спад и уровень из замера пространства). По хвосту после прямого звука
|
||||
считаются RT60 (обратное интегрирование Шрёдера, спад с -5 до -35 дБ, удвоенный) и энергия хвоста к энергии щелчка:
|
||||
|
||||
python3 tools/acoustics_check.py --reverb-dir папка [--rt-tol 0.35]
|
||||
|
||||
Проверяется: в поле хвоста нет (энергия хвоста меньше -90 дБ от щелчка), спад упорядочен поле < ущелье < тоннель <
|
||||
комната, у комнаты и тоннеля измеренный RT60 в пределах `--rt-tol` от того, что дают их посылки в слоты (колонка
|
||||
`RT60 expected`: сумма экспонент слотов, спад -5...-35 дБ; слой из двух слотов читается длиннее короткого).
|
||||
Только стандартная библиотека.
|
||||
"""
|
||||
import argparse
|
||||
@@ -98,6 +107,57 @@ def check_echo(path, expect, tol, rate, channels):
|
||||
return ok
|
||||
|
||||
|
||||
def reverb_track(path, rate, channels, dry_ms=60.0):
|
||||
"""(tail energy relative to the click in dB or None, RT60 in s or None) of a click's track."""
|
||||
raw = array.array("h")
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
raw.frombytes(data[: len(data) // 2 * 2])
|
||||
left = [s / 32768.0 for s in raw[0::channels]]
|
||||
cut = int(dry_ms * rate / 1000)
|
||||
dry = sum(v * v for v in left[:cut])
|
||||
tail = left[cut:]
|
||||
energy = sum(v * v for v in tail)
|
||||
if dry <= 0 or energy <= 0 or 10 * math.log10(energy / dry) < -90:
|
||||
return None, None
|
||||
edc, acc = [0.0] * len(tail), 0.0
|
||||
for i in range(len(tail) - 1, -1, -1):
|
||||
acc += tail[i] * tail[i]
|
||||
edc[i] = acc
|
||||
level = lambda i: 10 * math.log10(max(edc[i], 1e-30) / edc[0])
|
||||
a = next((i for i in range(len(tail)) if level(i) <= -5), None)
|
||||
b = next((i for i in range(len(tail)) if level(i) <= -35), None)
|
||||
rt = 2 * (b - a) / rate if a is not None and b is not None else None
|
||||
return 10 * math.log10(energy / dry), rt
|
||||
|
||||
|
||||
def check_reverb(folder, rate, channels, tol):
|
||||
import os
|
||||
want = {}
|
||||
with open(os.path.join(folder, "expected_reverb.txt")) as f:
|
||||
for line in f:
|
||||
name, decay, wet, effective = line.split()
|
||||
want[name] = (float(decay), float(wet), float(effective))
|
||||
got = {}
|
||||
print(f"{'scene':>8} {'decay of space':>16} {'RT60 expected':>14} {'measured RT60 s':>16} {'tail/click dB':>14}")
|
||||
for name in ("field", "gorge", "tunnel", "room"):
|
||||
level, rt = reverb_track(os.path.join(folder, name + ".raw"), rate, channels)
|
||||
got[name] = (level, rt)
|
||||
print(f"{name:>8} {want[name][0]:16.2f} {want[name][2]:14.2f} {('none' if rt is None else f'{rt:.2f}'):>16} {('none' if level is None else f'{level:.1f}'):>14}")
|
||||
ok = got["field"][1] is None
|
||||
print(f" field has no tail: {'ok' if ok else 'FAIL'}")
|
||||
order = [got[n][1] for n in ("gorge", "tunnel", "room")]
|
||||
ordered = all(v is not None for v in order) and order[0] < order[1] < order[2]
|
||||
print(f" gorge < tunnel < room: {'ok' if ordered else 'FAIL'}")
|
||||
close = True
|
||||
for n in ("tunnel", "room"):
|
||||
rt = got[n][1]
|
||||
good = rt is not None and abs(rt - want[n][2]) <= tol * want[n][2]
|
||||
close &= good
|
||||
print(f" {n} within {tol:.0%} of the RT60 its sends make: {'ok' if good else 'FAIL'}")
|
||||
return ok and ordered and close
|
||||
|
||||
|
||||
def db(a, b):
|
||||
return 10 * math.log10(max(a, 1e-20) / max(b, 1e-20))
|
||||
|
||||
@@ -107,6 +167,8 @@ def main():
|
||||
p.add_argument("open", nargs="?")
|
||||
p.add_argument("wall", nargs="?")
|
||||
p.add_argument("--echo", help="track with one click: check the delays of its echoes")
|
||||
p.add_argument("--reverb-dir", help="folder written by export_reverb_tracks: check the decay of room, tunnel, gorge, field")
|
||||
p.add_argument("--rt-tol", type=float, default=0.35, help="how far a measured RT60 may be from the predicted, as a fraction")
|
||||
p.add_argument("--echo-dir", help="folder written by export_echo_tracks: check gorge, field and room")
|
||||
p.add_argument("--expect", default="none", help="expected delays in ms, comma separated, or none")
|
||||
p.add_argument("--tol", type=float, default=1.0, help="ms a peak may be off")
|
||||
@@ -115,6 +177,8 @@ def main():
|
||||
p.add_argument("--min-high-cut", type=float, default=15.0, help="dB the band above 5 kHz must drop")
|
||||
p.add_argument("--max-low-loss", type=float, default=8.0, help="dB the band below 300 Hz may drop")
|
||||
a = p.parse_args()
|
||||
if a.reverb_dir:
|
||||
return 0 if check_reverb(a.reverb_dir, a.rate, a.channels, a.rt_tol) else 1
|
||||
if a.echo_dir:
|
||||
import os
|
||||
ok = True
|
||||
|
||||
+107
-1
@@ -7,7 +7,8 @@
|
||||
|
||||
Запуск: python3 tools/gen_models.py [--check]
|
||||
|
||||
Скрипт пишет во временный каталог и только потом переносит результат в airdef-mod/. Список созданных им файлов
|
||||
Скрипт пишет во временный каталог, проверяет набор (validate: ссылки моделей и текстур, JSON, PNG) и только потом
|
||||
переносит результат в airdef-mod/; при ошибке проверки ничего не записывает. Список созданных им файлов
|
||||
с sha256 лежит в airdef-mod/generated-manifest.txt (формат sha256sum). Удаляются и перезаписываются только файлы
|
||||
из этого списка и только если их не правили вручную; чужой файл на пути генерируемого останавливает запуск.
|
||||
--check ничего не пишет и выходит с кодом 1, если airdef-mod/ отличается от того, что дал бы запуск.
|
||||
@@ -1745,6 +1746,105 @@ def generate(out):
|
||||
return len(MODELS), len(ITEMS), len(TEXTURES)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- проверка набора до переноса
|
||||
def check_png(path, rel):
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
w, h = im.size
|
||||
im.load()
|
||||
except Exception as e: # битый файл или не PNG
|
||||
return [f"{rel}: PNG не открывается: {e}"]
|
||||
errs = []
|
||||
if w != h:
|
||||
errs.append(f"{rel}: текстура не квадратная, {w}x{h}")
|
||||
if w < 8 or w > 256 or w & (w - 1):
|
||||
errs.append(f"{rel}: размер {w}x{h}, ожидается степень двойки от 8 до 256")
|
||||
return errs
|
||||
|
||||
|
||||
def validate(stage):
|
||||
# Проверяет набор в каталоге stage до того, как он попадёт в airdef-mod/. Возвращает список ошибок.
|
||||
# Ссылки airdef:<путь> проверяем по набору; ссылки minecraft:... и без пространства имён — ванильные, их не проверяем.
|
||||
errors = []
|
||||
files, docs = set(), {}
|
||||
for dirpath, _, names in os.walk(stage):
|
||||
for name in sorted(names):
|
||||
path = os.path.join(dirpath, name)
|
||||
rel = os.path.relpath(path, stage).replace(os.sep, "/")
|
||||
files.add(rel)
|
||||
if name.endswith(".json"):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
docs[rel] = json.load(f)
|
||||
except (OSError, ValueError) as e:
|
||||
errors.append(f"{rel}: не разбирается как JSON: {e}")
|
||||
elif name.endswith(".png"):
|
||||
errors.extend(check_png(path, rel))
|
||||
|
||||
def check_ref(ref, kind, ext, owner):
|
||||
if not isinstance(ref, str) or not ref.startswith(NS + ":"):
|
||||
return
|
||||
path = ref[len(NS) + 1:]
|
||||
if not path or path.startswith("/") or ".." in path.split("/"):
|
||||
errors.append(f"{owner}: недопустимая ссылка {ref}")
|
||||
return
|
||||
rel = f"assets/{NS}/{kind}/{path}{ext}"
|
||||
if rel not in files:
|
||||
errors.append(f"{owner}: ссылка {ref} не найдена ({rel})")
|
||||
|
||||
def pick(prefix):
|
||||
return sorted(rel for rel in docs if rel.startswith(f"assets/{NS}/{prefix}"))
|
||||
|
||||
for rel in pick("models/"):
|
||||
doc = docs[rel]
|
||||
if not isinstance(doc, dict):
|
||||
errors.append(f"{rel}: модель должна быть объектом JSON")
|
||||
continue
|
||||
if "parent" in doc:
|
||||
check_ref(doc["parent"], "models", ".json", rel)
|
||||
tex = doc.get("textures", {})
|
||||
for key, val in tex.items():
|
||||
if isinstance(val, str) and val.startswith("#"):
|
||||
if val[1:] not in tex:
|
||||
errors.append(f"{rel}: текстура {val} не объявлена")
|
||||
else:
|
||||
check_ref(val, "textures", ".png", rel)
|
||||
for i, el in enumerate(doc.get("elements", [])):
|
||||
for corner in ("from", "to"):
|
||||
pos = el.get(corner, [])
|
||||
if len(pos) != 3 or any(not -16 <= c <= 32 for c in pos):
|
||||
errors.append(f"{rel}: элемент {i}, {corner} {pos} вне -16..32")
|
||||
for face, fd in el.get("faces", {}).items():
|
||||
tex_ref = fd.get("texture")
|
||||
if tex_ref is None:
|
||||
errors.append(f"{rel}: элемент {i}, грань {face} без texture")
|
||||
elif tex_ref.startswith("#"):
|
||||
if tex_ref[1:] not in tex:
|
||||
errors.append(f"{rel}: элемент {i}, грань {face}: {tex_ref} не объявлена")
|
||||
else:
|
||||
check_ref(tex_ref, "textures", ".png", rel)
|
||||
for rel in pick("blockstates/"):
|
||||
for variant in docs[rel].get("variants", {}).values():
|
||||
for m in (variant if isinstance(variant, list) else [variant]):
|
||||
check_ref(m.get("model"), "models", ".json", rel)
|
||||
for rel in pick("items/"):
|
||||
check_ref(docs[rel].get("model", {}).get("model"), "models", ".json", rel)
|
||||
# У каждого блока есть blockstate, и наоборот.
|
||||
blocks = {rel[len(f"assets/{NS}/models/block/"):] for rel in pick("models/block/")}
|
||||
states = {rel[len(f"assets/{NS}/blockstates/"):] for rel in pick("blockstates/")}
|
||||
for name in sorted(blocks - states):
|
||||
errors.append(f"models/block/{name}: нет blockstate")
|
||||
for name in sorted(states - blocks):
|
||||
errors.append(f"blockstates/{name}: нет модели блока")
|
||||
en = docs.get(f"assets/{NS}/lang/en_us.json")
|
||||
ru = docs.get(f"assets/{NS}/lang/ru_ru.json")
|
||||
if en is None or ru is None:
|
||||
errors.append("нет lang/en_us.json или lang/ru_ru.json")
|
||||
elif set(en) != set(ru):
|
||||
errors.append(f"lang: ключи en_us и ru_ru различаются: {sorted(set(en) ^ set(ru))[:5]}")
|
||||
return errors
|
||||
|
||||
|
||||
MANIFEST = os.path.join(ROOT, "generated-manifest.txt")
|
||||
|
||||
|
||||
@@ -1782,6 +1882,12 @@ def main():
|
||||
old = read_manifest()
|
||||
with tempfile.TemporaryDirectory() as stage:
|
||||
counts = generate(stage)
|
||||
errors = validate(stage)
|
||||
if errors:
|
||||
print(f"ПРОВЕРКА НАБОРА НЕ ПРОЙДЕНА, ошибок: {len(errors)}. Ничего не записано в {ROOT}.")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
sys.exit(1)
|
||||
new = {}
|
||||
for dirpath, dirs, names in os.walk(stage):
|
||||
dirs.sort()
|
||||
|
||||
Reference in New Issue
Block a user