Compare commits
1 Commits
v0.3.0-rc1
...
build-54
| Author | SHA1 | Date | |
|---|---|---|---|
| 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}");
|
||||
|
||||
+233
-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;
|
||||
@@ -1950,6 +2162,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 +2233,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 +2288,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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1272,6 +1275,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 +1349,7 @@ impl Sam {
|
||||
kills: 0,
|
||||
fired: 0,
|
||||
roles: spec.roles,
|
||||
doctrine: doctrine::Doctrine::default(),
|
||||
state: SamState::Search,
|
||||
launched: 0,
|
||||
health: 1.0,
|
||||
@@ -1509,8 +1515,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.
|
||||
@@ -1580,7 +1590,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,
|
||||
@@ -1710,6 +1722,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);
|
||||
@@ -2359,7 +2372,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 +2410,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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -660,7 +660,12 @@ impl Sim {
|
||||
let mut cancelled = 0;
|
||||
for (t, 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);
|
||||
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::Busy => {
|
||||
@@ -820,6 +825,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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