Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f9609327 | |||
| 3265eb63d2 | |||
| b43d471706 |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 265 B |
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 336 B |
|
Before Width: | Height: | Size: 400 B After Width: | Height: | Size: 241 B |
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 278 B |
|
Before Width: | Height: | Size: 400 B After Width: | Height: | Size: 297 B |
|
Before Width: | Height: | Size: 388 B After Width: | Height: | Size: 249 B |
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 297 B |
|
Before Width: | Height: | Size: 386 B After Width: | Height: | Size: 286 B |
|
Before Width: | Height: | Size: 402 B After Width: | Height: | Size: 307 B |
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 285 B |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 399 B After Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 409 B |
|
Before Width: | Height: | Size: 401 B After Width: | Height: | Size: 338 B |
|
Before Width: | Height: | Size: 400 B After Width: | Height: | Size: 316 B |
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 154 B |
|
Before Width: | Height: | Size: 386 B After Width: | Height: | Size: 139 B |
|
Before Width: | Height: | Size: 393 B After Width: | Height: | Size: 223 B |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 210 B |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 233 B |
|
Before Width: | Height: | Size: 408 B After Width: | Height: | Size: 184 B |
|
Before Width: | Height: | Size: 409 B After Width: | Height: | Size: 254 B |
|
Before Width: | Height: | Size: 393 B After Width: | Height: | Size: 151 B |
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 149 B |
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 269 B |
|
Before Width: | Height: | Size: 408 B After Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 394 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 464 B |
|
Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 257 B |
@@ -0,0 +1,201 @@
|
||||
//! The text of a saved layout (the autosave, the body of a scenario) read and checked as a whole before
|
||||
//! anything of the scene is touched (#86). `Layout::parse` is pure: it either returns every record with its
|
||||
//! values proven (finite, in range, within the limits) or the line and field that are wrong; `AirDef` swaps the
|
||||
//! scene for it only after that. Lines it doesn't know are skipped, so older and newer files read alike.
|
||||
use super::*;
|
||||
|
||||
/// Most systems and most objects one file may hold.
|
||||
pub const MAX_UNITS: usize = 2000;
|
||||
/// Largest coordinate: the world's border and then some.
|
||||
const COORD: f64 = 3.0e7;
|
||||
/// Largest count (missiles, bursts, cargo, reload ticks).
|
||||
const COUNT: f64 = 1.0e6;
|
||||
|
||||
pub struct SamRec {
|
||||
pub kind: SamKind,
|
||||
pub pos: DVec3,
|
||||
pub heading: f64,
|
||||
pub roles: Option<u8>,
|
||||
pub health: Option<f64>,
|
||||
pub cargo: Option<u32>,
|
||||
/// Missiles, bursts, empty, reloading: older saves have no ammunition (full).
|
||||
pub ammo: Option<(u32, u32, bool, u32)>,
|
||||
pub dest: Option<DVec3>,
|
||||
/// The parts, from the sensor on; older saves have fewer or none.
|
||||
pub comps: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Layout {
|
||||
pub sams: Vec<SamRec>,
|
||||
pub targets: Vec<(DVec3, f64)>,
|
||||
pub damage: Option<bool>,
|
||||
pub seed: Option<u64>,
|
||||
pub wave: Option<Vec<String>>,
|
||||
pub far: Option<bool>,
|
||||
pub effects: Option<(bool, bool)>,
|
||||
pub roles: Vec<(SamKind, u8)>,
|
||||
}
|
||||
|
||||
/// One line's words, with the line number for the messages.
|
||||
struct Line<'a> {
|
||||
n: usize,
|
||||
word: &'a str,
|
||||
w: Vec<&'a str>,
|
||||
}
|
||||
|
||||
impl Line<'_> {
|
||||
fn bad(&self, field: &str, why: &str) -> String {
|
||||
format!("строка {}: «{}», поле {field}: {why}", self.n, self.word)
|
||||
}
|
||||
/// A finite number in `lo..=hi`; `None` where the field is absent.
|
||||
fn num(&self, i: usize, field: &str, lo: f64, hi: f64) -> Result<Option<f64>, String> {
|
||||
let Some(v) = self.w.get(i) else { return Ok(None) };
|
||||
let x: f64 = v.parse().map_err(|_| self.bad(field, &format!("«{v}» не число")))?;
|
||||
if !x.is_finite() {
|
||||
return Err(self.bad(field, &format!("«{v}» не конечное число")));
|
||||
}
|
||||
if x < lo || x > hi {
|
||||
return Err(self.bad(field, &format!("{x} вне допустимого {lo}..{hi}")));
|
||||
}
|
||||
Ok(Some(x))
|
||||
}
|
||||
fn need(&self, i: usize, field: &str, lo: f64, hi: f64) -> Result<f64, String> {
|
||||
self.num(i, field, lo, hi)?.ok_or_else(|| self.bad(field, "строка оборвана"))
|
||||
}
|
||||
fn flag(&self, i: usize, field: &str) -> Result<Option<bool>, String> {
|
||||
match self.w.get(i) {
|
||||
None => Ok(None),
|
||||
Some(&"true") => Ok(Some(true)),
|
||||
Some(&"false") => Ok(Some(false)),
|
||||
Some(v) => Err(self.bad(field, &format!("«{v}»: нужно true или false"))),
|
||||
}
|
||||
}
|
||||
fn kind(&self, i: usize) -> Result<SamKind, String> {
|
||||
let v = self.w.get(i).ok_or_else(|| self.bad("тип", "строка оборвана"))?;
|
||||
SamKind::from_key(v).ok_or_else(|| self.bad("тип", &format!("неизвестный тип «{v}»")))
|
||||
}
|
||||
fn xyz(&self, i: usize, field: &str) -> Result<DVec3, String> {
|
||||
Ok(DVec3::new(self.need(i, field, -COORD, COORD)?, self.need(i + 1, field, -COORD, COORD)?, self.need(i + 2, field, -COORD, COORD)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn parse(text: &str) -> Result<Layout, String> {
|
||||
let mut out = Layout::default();
|
||||
for (n, raw) in text.lines().enumerate() {
|
||||
let w: Vec<&str> = raw.split_whitespace().collect();
|
||||
let Some(&word) = w.first() else { continue };
|
||||
let l = Line { n: n + 1, word, w };
|
||||
match word {
|
||||
"sam" => {
|
||||
if out.sams.len() >= MAX_UNITS {
|
||||
return Err(l.bad("число комплексов", &format!("больше {MAX_UNITS}")));
|
||||
}
|
||||
let kind = l.kind(1)?;
|
||||
let pos = l.xyz(2, "координаты")?;
|
||||
let heading = l.need(5, "курс", -1.0e6, 1.0e6)?;
|
||||
let roles = l.num(6, "роли", 0.0, 255.0)?.map(|r| r as u8);
|
||||
let health = l.num(7, "прочность", 0.0, 1.0)?;
|
||||
let cargo = l.num(8, "груз", 0.0, COUNT)?.map(|c| c as u32);
|
||||
let ammo = match (l.num(9, "ракеты", 0.0, COUNT)?, l.num(10, "очереди", 0.0, COUNT)?) {
|
||||
(Some(m), Some(b)) => Some((m as u32, b as u32, l.num(11, "пуст", 0.0, 1.0)? == Some(1.0), l.num(12, "перезарядка", 0.0, COUNT)?.map_or(0, |r| r as u32))),
|
||||
_ => None,
|
||||
};
|
||||
// "- - -": no destination.
|
||||
let dest = if l.w.get(13) == Some(&"-") { None } else if l.w.len() > 13 { Some(l.xyz(13, "цель движения")?) } else { None };
|
||||
let mut comps = Vec::new();
|
||||
for k in 0..6 {
|
||||
match l.num(16 + k, "часть", 0.0, 1.0)? {
|
||||
Some(c) => comps.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out.sams.push(SamRec { kind, pos, heading, roles, health, cargo, ammo, dest, comps });
|
||||
}
|
||||
"target" => {
|
||||
if out.targets.len() >= MAX_UNITS {
|
||||
return Err(l.bad("число объектов", &format!("больше {MAX_UNITS}")));
|
||||
}
|
||||
let pos = l.xyz(1, "координаты")?;
|
||||
let hp = l.num(4, "прочность", 0.0, 100.0)?.unwrap_or(100.0);
|
||||
out.targets.push((pos, hp));
|
||||
}
|
||||
"damage" => out.damage = l.flag(1, "значение")?,
|
||||
"far" => out.far = 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("значение", "строка оборвана"))?;
|
||||
out.seed = Some(v.parse::<u64>().map_err(|_| l.bad("значение", &format!("«{v}» не число")))?);
|
||||
}
|
||||
"wave" => {
|
||||
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());
|
||||
}
|
||||
"roles" => {
|
||||
let kind = l.kind(1)?;
|
||||
out.roles.push((kind, l.need(2, "роли", 0.0, 255.0)? as u8));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> &'static str {
|
||||
SamKind::Patriot.key()
|
||||
}
|
||||
|
||||
// #86: the issue's file, and the other ways a saved state can be wrong: refused whole, with the line
|
||||
#[test]
|
||||
fn a_damaged_state_is_refused_as_a_whole_with_its_line_and_field() {
|
||||
let good = format!("sam {} 10 70 20 0.5\ntarget 1 2 3 100\n", key());
|
||||
assert!(Layout::parse(&good).is_ok());
|
||||
let cases: Vec<(String, usize, &str)> = vec![
|
||||
("target NaN inf 0 -100\nsam s400 NaN 70 0 0\n".into(), 1, "координаты"),
|
||||
(format!("{good}target 1 inf 3 100\n"), 3, "не конечное"),
|
||||
(format!("{good}target 1 2 3 -100\n"), 3, "прочность"),
|
||||
(format!("{good}target 1 2 3 NaN\n"), 3, "не конечное"),
|
||||
(format!("{good}target 1 2\n"), 3, "оборвана"),
|
||||
(format!("{good}sam {} 1 2\n", key()), 3, "оборвана"),
|
||||
(format!("{good}sam nosuchkind 1 2 3 0\n"), 3, "тип"),
|
||||
(format!("{good}sam {} 1 2 3 0 0 7.5\n", key()), 3, "прочность"),
|
||||
(format!("{good}sam {} 1 2 3 0 0 1 0 1 1 0 0 - - - 0.5 abc\n", key()), 3, "часть"),
|
||||
(format!("{good}sam {} 1e400 2 3 0\n", key()), 3, "координаты"),
|
||||
(format!("{good}seed twelve\n"), 3, "значение"),
|
||||
(format!("{good}wave 1,2,x 0 300 0 1\n"), 3, "налёт"),
|
||||
(format!("{good}damage maybe\n"), 3, "значение"),
|
||||
];
|
||||
for (text, line, what) in cases {
|
||||
let e = Layout::parse(&text).err().unwrap_or_else(|| panic!("accepted: {text}"));
|
||||
assert!(e.contains(&format!("строка {line}")) && e.contains(what), "{e} for {text}");
|
||||
}
|
||||
let many = "target 0 0 0 100\n".repeat(MAX_UNITS + 1);
|
||||
assert!(Layout::parse(&many).is_err(), "no limit on the number of objects");
|
||||
}
|
||||
|
||||
// #86: files made by older builds (no ammunition, no parts, no destination marks) and unknown lines still load
|
||||
#[test]
|
||||
fn older_valid_saves_still_load() {
|
||||
let old = format!(
|
||||
"sam {k} 10.5 70 -20 1.57\nsam {k} 1 2 3 0 3 0.8 2\nsam {k} 1 2 3 0 3 0.8 2 4 12 1 30 - - -\nsam {k} 1 2 3 0 3 1 0 4 12 0 0 5 70 5 1 0.9 0.8 0.7 0.6 0.5\ntarget 5 6 7\ntarget 5 6 7 0\ndamage false\nfar true\neffects true false\nroles {k} 3\nfuture-line with words\nscenario 1\nname x\nworld state-0\nseed 123456789\nwave 0,1,0,2,0,0,0,0,0 1 300 0 1\n",
|
||||
k = key()
|
||||
);
|
||||
let l = Layout::parse(&old).expect("an old valid save was refused");
|
||||
assert_eq!((l.sams.len(), l.targets.len()), (4, 2));
|
||||
assert_eq!(l.targets[0].1, 100.0);
|
||||
assert_eq!(l.sams[2].ammo, Some((4, 12, true, 30)));
|
||||
assert!(l.sams[2].dest.is_none() && l.sams[3].dest == Some(DVec3::new(5.0, 70.0, 5.0)));
|
||||
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);
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use horizon_mod_sdk::{
|
||||
};
|
||||
|
||||
mod cinema;
|
||||
mod layout;
|
||||
mod film;
|
||||
mod monitor;
|
||||
mod panel;
|
||||
@@ -783,83 +784,79 @@ impl AirDef {
|
||||
|
||||
fn load_from(&mut self, key: &str) {
|
||||
let Some(bytes) = storage::get(key) else { return };
|
||||
self.apply_state(&String::from_utf8_lossy(&bytes));
|
||||
if let Err(e) = self.apply_state(&String::from_utf8_lossy(&bytes)) {
|
||||
horizon_mod_sdk::warn(&format!("saved state not loaded: {e}"));
|
||||
// The next autosave would write over it: keep the refused file for a look or a repair.
|
||||
if let Err(e) = storage::set(&format!("{key}-refused"), &bytes) {
|
||||
horizon_mod_sdk::warn(&format!("could not keep the refused state: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the state in `text` (`state_text`'s lines; unknown lines are skipped, so older and
|
||||
/// newer files read alike).
|
||||
fn apply_state(&mut self, text: &str) {
|
||||
for line in text.lines() {
|
||||
let w: Vec<&str> = line.split_whitespace().collect();
|
||||
let num = |i: usize| w.get(i).and_then(|v| v.parse::<f64>().ok());
|
||||
match w.first() {
|
||||
Some(&"sam") => {
|
||||
if let (Some(kind), Some(x), Some(y), Some(z), Some(h)) =
|
||||
(w.get(1).and_then(|k| SamKind::from_key(k)), num(2), num(3), num(4), num(5))
|
||||
{
|
||||
let id = self.sim.id();
|
||||
let mut sam = Sam::new(id, kind, DVec3::new(x, y, z), h);
|
||||
if let Some(r) = w.get(6).and_then(|v| v.parse().ok()) {
|
||||
sam.roles = r;
|
||||
}
|
||||
if let Some(h) = num(7) {
|
||||
sam.health = h.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(c) = num(8) {
|
||||
sam.cargo = c as u32;
|
||||
}
|
||||
// Older saves have no ammunition: full, as before.
|
||||
if let (Some(m), Some(b)) = (num(9), num(10)) {
|
||||
sam.missiles = (m as u32).min(sam.missiles);
|
||||
sam.bursts = (b as u32).min(sam.bursts);
|
||||
sam.empty = num(11) == Some(1.0);
|
||||
sam.reloading = num(12).map_or(0, |r| r as u32);
|
||||
}
|
||||
let dest = match (num(13), num(14), num(15)) {
|
||||
(Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)),
|
||||
_ => None,
|
||||
};
|
||||
// The parts: absent in older saves, which load with every part whole.
|
||||
for (k, v) in sam.comps.0.iter_mut().enumerate() {
|
||||
if let Some(c) = num(16 + k) {
|
||||
*v = c.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
sam.fit_cells();
|
||||
self.sim.sams.push(sam);
|
||||
if let Some(to) = dest {
|
||||
let i = self.sim.sams.len() - 1;
|
||||
self.sim.order_move(i, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(&"target") => {
|
||||
if let (Some(x), Some(y), Some(z)) = (num(1), num(2), num(3)) {
|
||||
self.sim.targets.push(DVec3::new(x, y, z));
|
||||
self.sim.target_health.push(num(4).unwrap_or(100.0));
|
||||
}
|
||||
}
|
||||
Some(&"damage") => self.sim.damage = w.get(1) != Some(&"false"),
|
||||
// A scenario's: the random generator's state, and the wave menu's raid.
|
||||
Some(&"seed") => {
|
||||
if let Some(v) = w.get(1).and_then(|v| v.parse::<u64>().ok()) {
|
||||
self.sim.rng = sim::Rng::new(v);
|
||||
}
|
||||
}
|
||||
Some(&"wave") => self.wave.parse(&w[1..]),
|
||||
Some(&"far") => self.far = w.get(1) != Some(&"false"),
|
||||
Some(&"effects") => {
|
||||
let shader = w.get(1) != Some(&"false");
|
||||
let hd = w.get(2) == Some(&"true");
|
||||
self.set_effects(shader, hd);
|
||||
}
|
||||
Some(&"roles") => {
|
||||
if let (Some(kind), Some(r)) = (w.get(1).and_then(|k| SamKind::from_key(k)), w.get(2).and_then(|v| v.parse().ok())) {
|
||||
self.sim.roles.insert(kind, r);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
/// newer files read alike). The whole text is read and checked first: a bad line refuses all of it,
|
||||
/// naming the line and field, and the scene stays as it was (#86).
|
||||
fn apply_state(&mut self, text: &str) -> Result<(), String> {
|
||||
let layout = layout::Layout::parse(text)?;
|
||||
self.commit_layout(layout);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a checked layout to the scene: nothing here can fail.
|
||||
fn commit_layout(&mut self, layout: layout::Layout) {
|
||||
for r in layout.sams {
|
||||
let id = self.sim.id();
|
||||
let mut sam = Sam::new(id, r.kind, r.pos, r.heading);
|
||||
if let Some(v) = r.roles {
|
||||
sam.roles = v;
|
||||
}
|
||||
if let Some(h) = r.health {
|
||||
sam.health = h;
|
||||
}
|
||||
if let Some(c) = r.cargo {
|
||||
sam.cargo = c;
|
||||
}
|
||||
// Older saves have no ammunition: full, as before.
|
||||
if let Some((m, b, empty, reloading)) = r.ammo {
|
||||
sam.missiles = m.min(sam.missiles);
|
||||
sam.bursts = b.min(sam.bursts);
|
||||
sam.empty = empty;
|
||||
sam.reloading = reloading;
|
||||
}
|
||||
// The parts: absent in older saves, which load with every part whole.
|
||||
for (v, c) in sam.comps.0.iter_mut().zip(&r.comps) {
|
||||
*v = *c;
|
||||
}
|
||||
sam.fit_cells();
|
||||
self.sim.sams.push(sam);
|
||||
if let Some(to) = r.dest {
|
||||
let i = self.sim.sams.len() - 1;
|
||||
self.sim.order_move(i, to);
|
||||
}
|
||||
}
|
||||
for (pos, hp) in layout.targets {
|
||||
self.sim.targets.push(pos);
|
||||
self.sim.target_health.push(hp);
|
||||
}
|
||||
if let Some(d) = layout.damage {
|
||||
self.sim.damage = d;
|
||||
}
|
||||
// A scenario's: the random generator, and the wave menu's raid.
|
||||
if let Some(v) = layout.seed {
|
||||
self.sim.reseed(v);
|
||||
}
|
||||
if let Some(w) = layout.wave {
|
||||
let w: Vec<&str> = w.iter().map(String::as_str).collect();
|
||||
self.wave.parse(&w);
|
||||
}
|
||||
if let Some(f) = layout.far {
|
||||
self.far = f;
|
||||
}
|
||||
if let Some((shader, hd)) = layout.effects {
|
||||
self.set_effects(shader, hd);
|
||||
}
|
||||
for (kind, r) in layout.roles {
|
||||
self.sim.roles.insert(kind, r);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ impl AirDef {
|
||||
if !force && world != self.state_key() {
|
||||
return Err("Сценарий сделан в другом мире: координаты могут не подойти (загрузить всё равно: добавьте force)".into());
|
||||
}
|
||||
// The whole body is read and checked before anything is touched (#86).
|
||||
let layout = layout::Layout::parse(text).map_err(|e| format!("Сценарий не загружен, сцена осталась прежней: {e}"))?;
|
||||
if self.replay.is_some() {
|
||||
self.stop_replay();
|
||||
}
|
||||
@@ -67,7 +69,7 @@ impl AirDef {
|
||||
self.moving = None;
|
||||
self.pending.clear();
|
||||
render::set_models(&[]);
|
||||
self.apply_state(text);
|
||||
self.commit_layout(layout);
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1393,7 +1393,13 @@ pub struct Sim {
|
||||
/// The next blast's id (separate from `next`, so the other units' ids stay as they were).
|
||||
blast_seq: u32,
|
||||
next: u32,
|
||||
/// The combat randomness: what is rolled for aim, hits, damage and raids. Drawing, sound and camera never
|
||||
/// spend it (#85).
|
||||
pub rng: Rng,
|
||||
/// The decorative randomness (flicker, pitch, spin, scatter of tracers and debris), and the ids of what is
|
||||
/// only drawn: neither moves with the graphics settings of the combat.
|
||||
vis: Rng,
|
||||
fx_next: u32,
|
||||
pub tick: u64,
|
||||
/// The game's tick number (the one `tick` is called with), for a recording to know the world's time by.
|
||||
pub engine_tick: u64,
|
||||
@@ -1447,6 +1453,15 @@ pub struct Sim {
|
||||
|
||||
/// Ids: objects get multiples of 16, their parts the low bits.
|
||||
const PARTS: u32 = 16;
|
||||
/// Ids of what is only drawn (debris, fireball models, tracers) come from their own range and counter, so that
|
||||
/// the number of effects never moves the ids of the combat's objects (#85).
|
||||
const FX_IDS: u32 = 0x2000_0000;
|
||||
|
||||
/// The next id for a drawn-only thing.
|
||||
fn fx_ident(counter: &mut u32) -> u32 {
|
||||
*counter = (*counter + 1) % 0x0100_0000;
|
||||
FX_IDS + *counter * PARTS
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
@@ -1461,6 +1476,8 @@ impl Sim {
|
||||
blast_seq: 0,
|
||||
next: 1,
|
||||
rng: Rng::new(seed),
|
||||
vis: Rng::new(seed ^ 0x5DEE_CE66_D1CE_4E5B),
|
||||
fx_next: 0,
|
||||
tick: 0,
|
||||
engine_tick: 0,
|
||||
kills: 0,
|
||||
@@ -1491,6 +1508,14 @@ impl Sim {
|
||||
spot_hd: false,
|
||||
}
|
||||
}
|
||||
/// A scenario's start: the combat generator, the dice of stateless rolls and the decorative generator all
|
||||
/// come from `state` alone, whatever was played before. The clock and the id counter go on (what is in the
|
||||
/// world keeps its ids), so a restart repeats the draws, not the whole fight.
|
||||
pub fn reseed(&mut self, state: u64) {
|
||||
self.rng = Rng::new(state);
|
||||
self.vis = Rng::new(state ^ 0x5DEE_CE66_D1CE_4E5B);
|
||||
self.seed = state.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1;
|
||||
}
|
||||
pub fn id(&mut self) -> u32 {
|
||||
self.next += 1;
|
||||
self.next * PARTS
|
||||
@@ -1645,7 +1670,7 @@ impl Sim {
|
||||
} else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) {
|
||||
d.pos = from.lerp(d.pos, t);
|
||||
}
|
||||
if self.rng.chance(0.5) {
|
||||
if self.vis.chance(0.5) {
|
||||
out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1);
|
||||
}
|
||||
out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2);
|
||||
@@ -1976,14 +2001,14 @@ impl Sim {
|
||||
}
|
||||
Engine::Jet(up, back, size) => {
|
||||
let at = d.pos + rot * DVec3::new(0.0, up * s, back * s);
|
||||
let flicker = 0.7 + 0.3 * self.rng.f();
|
||||
let flicker = 0.7 + 0.3 * self.vis.f();
|
||||
out.model(d.id + 1, "airdef:geran3_flame", at, rot, s * size * flicker, true);
|
||||
}
|
||||
Engine::Rocket(back, size) => {
|
||||
// Ballistic missiles fly far and high: their glow keeps a size on screen, so they are seen
|
||||
// all the way (the motor while it burns, then the hot body).
|
||||
let far = player.map_or(1.0, |p| (d.pos.distance(p) / 140.0).max(1.0));
|
||||
let flicker = 0.8 + 0.2 * self.rng.f();
|
||||
let flicker = 0.8 + 0.2 * self.vis.f();
|
||||
if d.flight != Flight::Ballistic || d.age < 60 {
|
||||
let size = if d.flight == Flight::Ballistic { size * far.sqrt() } else { size };
|
||||
let at = d.pos + rot * DVec3::new(0.0, 0.0, back * s + size * flicker);
|
||||
@@ -2019,7 +2044,7 @@ impl Sim {
|
||||
out.particles("minecraft:flame", pos, size as f32 * 0.3, DVec3::ZERO, 0.2, n);
|
||||
out.particles("minecraft:lava", pos, 1.0, DVec3::ZERO, 0.0, 16);
|
||||
out.particles("minecraft:campfire_signal_smoke", pos, 1.2, DVec3::new(0.0, 0.04, 0.0), 0.01, if big { 30 } else { 10 });
|
||||
let pitch = 0.9 + self.rng.f() as f32 * 0.2;
|
||||
let pitch = 0.9 + self.vis.f() as f32 * 0.2;
|
||||
let event = ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][power];
|
||||
out.sound(event, pos, 1.0, if small { pitch * 1.15 } else { pitch });
|
||||
if power >= 3 {
|
||||
@@ -2058,8 +2083,8 @@ impl Sim {
|
||||
if self.shader {
|
||||
return;
|
||||
}
|
||||
let id = self.id();
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.rng.s() * 0.2, self.rng.s() * 0.2, self.rng.s() * 0.2);
|
||||
let id = fx_ident(&mut self.fx_next);
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.2, self.vis.s() * 0.2, self.vis.s() * 0.2);
|
||||
self.fx.push((id, Fx::Fireball { pos, size, age: 0, life, spin }));
|
||||
}
|
||||
|
||||
@@ -2095,11 +2120,11 @@ impl Sim {
|
||||
out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4);
|
||||
out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25);
|
||||
out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30);
|
||||
out.sound("airdef:blast.air", pos, gain, 1.0 + self.rng.f() as f32 * 0.2);
|
||||
out.sound("airdef:blast.air", pos, gain, 1.0 + self.vis.f() as f32 * 0.2);
|
||||
for _ in 0..5 {
|
||||
let id = self.id();
|
||||
let v = vel + DVec3::new(self.rng.s(), self.rng.f() * 0.8, self.rng.s()) * 0.35;
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.rng.s() * 0.4, self.rng.s() * 0.4, self.rng.s() * 0.4);
|
||||
let id = fx_ident(&mut self.fx_next);
|
||||
let v = vel + DVec3::new(self.vis.s(), self.vis.f() * 0.8, self.vis.s()) * 0.35;
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.4, self.vis.s() * 0.4, self.vis.s() * 0.4);
|
||||
self.fx.push((id, Fx::Debris { pos, vel: v, rot: DQuat::IDENTITY, spin, age: 0 }));
|
||||
}
|
||||
}
|
||||
@@ -2333,16 +2358,15 @@ impl Sim {
|
||||
let dir = (lead - muzzle).normalize_or(DVec3::NEG_Z);
|
||||
let len = muzzle.distance(lead).min(gun.range * 1.2);
|
||||
// Every round is a red tracer flying out at the shell's speed.
|
||||
let scatter = DVec3::new(self.rng.s(), self.rng.s(), self.rng.s()) * 0.012;
|
||||
let id = self.next * PARTS + 0x0800_0000;
|
||||
self.next += 1;
|
||||
let scatter = DVec3::new(self.vis.s(), self.vis.s(), self.vis.s()) * 0.012;
|
||||
let id = fx_ident(&mut self.fx_next);
|
||||
tracers.push((id, muzzle + dir * 1.5, (dir + scatter).normalize() * gun.shell, (len / gun.shell) as u32 + 2));
|
||||
out.particles("minecraft:smoke", muzzle, 0.1, dir * 0.05, 0.01, 2);
|
||||
}
|
||||
// A gun sound holds a few shots: one every other burst.
|
||||
if sam.bursts % 2 == 0 {
|
||||
let event = if sam.kind == SamKind::Pantsir { "airdef:gun.pantsir" } else { "airdef:gun.gepard" };
|
||||
out.sound(event, mount, 1.0, 0.95 + self.rng.f() as f32 * 0.1);
|
||||
out.sound(event, mount, 1.0, 0.95 + self.vis.f() as f32 * 0.1);
|
||||
}
|
||||
// At night a gun sees what is lit (a searchlight, a flare) and little else.
|
||||
let dark = if self.night && !self.lit.contains(&t.id) { lights::DARK_HITS } else { 1.0 };
|
||||
@@ -2448,7 +2472,7 @@ impl Sim {
|
||||
out.particles("minecraft:cloud", muzzle - dir * 2.0, 1.4, DVec3::new(0.0, 0.02, 0.0), 0.06, 70);
|
||||
out.particles("minecraft:campfire_cosy_smoke", muzzle - dir * 3.0, 1.2, DVec3::ZERO, 0.02, 20);
|
||||
let event = if kind == MissileKind::M57e6 { "airdef:launch.pantsir" } else { "airdef:launch.patriot" };
|
||||
out.sound(event, muzzle, 1.0, 0.95 + self.rng.f() as f32 * 0.1);
|
||||
out.sound(event, muzzle, 1.0, 0.95 + self.vis.f() as f32 * 0.1);
|
||||
}
|
||||
}
|
||||
for (id, pos, vel, life) in tracers {
|
||||
@@ -2478,7 +2502,7 @@ impl Sim {
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let s = self.sams.iter().find(|s| s.id == m.sam)?;
|
||||
(s.alive() && !s.moving()).then_some(())?;
|
||||
(s.alive() && !s.moving() && s.link_range() > 0.0).then_some(())?;
|
||||
Some(((m.sam, m.target), *s.track(m.target)?))
|
||||
})
|
||||
.collect();
|
||||
@@ -2507,12 +2531,12 @@ impl Sim {
|
||||
let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6);
|
||||
let to = t.pos - m.pos;
|
||||
let in_view = to.length() < range && to.normalize_or_zero().dot(dir) > 0.5;
|
||||
if in_view && !matches!(ground.world.probe(m.pos, t.pos, now), world::Sweep::Hit { .. }) {
|
||||
// Only a line of sight the world has confirmed counts: blocks not read (Unknown) are not air (#75).
|
||||
if in_view && matches!(ground.world.probe(m.pos, t.pos, now), world::Sweep::Clear) {
|
||||
m.seen = now;
|
||||
}
|
||||
if m.seen > 0 && now.saturating_sub(m.seen) <= REACQUIRE {
|
||||
// The seeker measures the target (the track of its own, newer than any link's).
|
||||
m.fix = Some(Fix { pos: if m.seen == now { t.pos } else { m.fix.map_or(t.pos, |f| f.pos) }, vel: t.vel, tick: if m.seen == now { now } else { m.fix.map_or(now, |f| f.tick) }, seeker: true });
|
||||
// The seeker measures the target: position and velocity, now. Out of sight (behind a hill,
|
||||
// an unread section) nothing new is measured and the last fix is carried on as it was.
|
||||
m.fix = Some(Fix { pos: t.pos, vel: t.vel, tick: now, seeker: true });
|
||||
}
|
||||
}
|
||||
// Nothing new for too long and no seeker on it: the interceptor has lost its target.
|
||||
@@ -2544,6 +2568,9 @@ impl Sim {
|
||||
let before = m.pos;
|
||||
m.vel = new * speed;
|
||||
m.pos += m.vel;
|
||||
let end = m.pos;
|
||||
// Where the exact shape is still being asked for, the contact point (by the cube) bounds the fuse (#74).
|
||||
let mut waiting: Option<f64> = None;
|
||||
// The first block on the way this tick; a fuse that goes off earlier along the way still counts.
|
||||
ground.ahead(m.pos, m.vel, 10.0, now);
|
||||
let sweep = ground.trace(m.id | 0x8000_0000, before, m.pos, m.age <= 12, now);
|
||||
@@ -2561,16 +2588,19 @@ impl Sim {
|
||||
}
|
||||
} else if let world::Sweep::Wait { t } = sweep {
|
||||
m.pos = before.lerp(m.pos, (t - 1e-6).max(0.0));
|
||||
waiting = Some(t);
|
||||
} else if matches!(sweep, world::Sweep::Clear) {
|
||||
m.hold = 0;
|
||||
}
|
||||
if let Some(t) = target {
|
||||
// Closest approach this tick, both moving (fast targets cross a fuse in a tick).
|
||||
let rel0 = (t.pos - t.vel) - before;
|
||||
let rel1 = t.pos - m.pos;
|
||||
// Waiting for the shape: the fuse is judged on the whole step against the cube's face, as for a
|
||||
// known hit, so that a pending answer is neither a free pass nor a different outcome.
|
||||
let rel1 = t.pos - if waiting.is_some() { end } else { m.pos };
|
||||
let delta = rel1 - rel0;
|
||||
let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0);
|
||||
if (rel0 + delta * k).length() < spec.fuse && world.is_none_or(|(w, _)| k <= w) {
|
||||
if (rel0 + delta * k).length() < spec.fuse && world.map(|w| w.0).or(waiting).is_none_or(|w| k <= w) {
|
||||
// A target faster than the interceptor is hard to hit.
|
||||
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
|
||||
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0);
|
||||
@@ -2660,7 +2690,7 @@ impl Sim {
|
||||
out.model(m.id, spec.model, m.pos, rot, spec.scale, false);
|
||||
if m.age > spec.cold && spec.flame > 0.0 {
|
||||
let back = (spec.rear - 8.0) / 16.0 * spec.scale + spec.flame;
|
||||
let size = spec.flame * if m.age < 40 { 1.2 } else { 0.8 } * (0.85 + 0.15 * self.rng.f());
|
||||
let size = spec.flame * if m.age < 40 { 1.2 } else { 0.8 } * (0.85 + 0.15 * self.vis.f());
|
||||
out.model(m.id + 1, "airdef:flame", m.pos + rot * DVec3::new(0.0, 0.0, back), rot, size, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +442,51 @@ fn missile_cannot_pass_through_a_wall() {
|
||||
assert!(sim.blasts.iter().all(|b| b.at.x < 3.5), "burst behind the wall");
|
||||
}
|
||||
|
||||
// #74: a pending exact answer is no free pass for the fuse: the same outcome whether the reply is late or not
|
||||
#[test]
|
||||
fn pending_exact_answer_does_not_let_the_fuse_kill_through_a_wall() {
|
||||
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
|
||||
let run = |seed: u64, target_x: f64, reply: bool| {
|
||||
let mut sim = Sim::new(seed);
|
||||
let mut ground = Ground::default();
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(target_x, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
sim.drones[0].engaged = 1;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.tick = 20;
|
||||
let fix = Fix { pos: DVec3::new(target_x, 100.0, 0.0), vel: DVec3::ZERO, tick: 20, seeker: false };
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 6.0, target, sam: 0, age: 20, hold: 0, fix: Some(fix), seen: 0 });
|
||||
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
|
||||
let mut first = 0;
|
||||
for i in 0..4 {
|
||||
sim.tick += 1;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
if i == 0 {
|
||||
first = sim.kills;
|
||||
}
|
||||
let wanted = ground.world.take_wanted(sim.tick);
|
||||
if reply {
|
||||
ground.world.serve(&src, wanted, sim.tick);
|
||||
} else {
|
||||
// Sections only: the exact rays are never answered inside the test.
|
||||
let answers = wanted.into_iter().map(|sec| world::SectionAnswer { sec, fill: world::section_of(&src, sec) }).collect();
|
||||
ground.world.answer(answers, sim.tick);
|
||||
}
|
||||
}
|
||||
(first, sim.kills, sim.drones[0].alive())
|
||||
};
|
||||
for seed in 1..=10 {
|
||||
// Just behind the wall, inside the fuse's reach of a missile held at its face.
|
||||
for reply in [true, false] {
|
||||
let (first, total, alive) = run(seed, 5.5, reply);
|
||||
assert_eq!((first, total, alive), (0, 0, true), "seed {seed} reply {reply}: kill through the wall");
|
||||
}
|
||||
}
|
||||
// In front of the wall the fuse still works, with the reply late or on time.
|
||||
let kills = |reply| (1..=10).filter(|&seed| run(seed, 2.0, reply).1 > 0).count();
|
||||
assert!(kills(true) > 0 && kills(true) == kills(false), "{} vs {}", kills(true), kills(false));
|
||||
}
|
||||
|
||||
// #42, #43: a bridge deck above the flight is no ground under it
|
||||
#[test]
|
||||
fn flight_under_a_bridge_is_not_a_ground_hit() {
|
||||
@@ -1087,6 +1132,89 @@ fn partial_blocks_use_exact_shapes() {
|
||||
assert!(matches!(last, world::Sweep::Hit { .. }), "{last:?}");
|
||||
}
|
||||
|
||||
// #73: an exact answer belongs to the segment and the geometry it was asked of
|
||||
#[test]
|
||||
fn exact_answers_are_not_reused_for_other_segments_or_changed_blocks() {
|
||||
use std::cell::Cell;
|
||||
let cube = Cell::new(false);
|
||||
let world = Shaped(|x, y, z| {
|
||||
if y < 64 {
|
||||
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
|
||||
} else if (x, y, z) == (10, 70, 0) {
|
||||
Some(vec![[0.0, 0.0, 0.0, 1.0, if cube.get() { 1.0 } else { 0.5 }, 1.0]])
|
||||
} else {
|
||||
Some(vec![])
|
||||
}
|
||||
});
|
||||
let mut g = Ground::default();
|
||||
g.world.changed(1, false, &[]);
|
||||
let (upper_a, upper_b) = (DVec3::new(9.5, 70.75, 0.5), DVec3::new(11.5, 70.75, 0.5));
|
||||
let (lower_a, lower_b) = (DVec3::new(9.5, 70.25, 0.5), DVec3::new(11.5, 70.25, 0.5));
|
||||
// Settles a step: waits for the game's answers the way a held flight does.
|
||||
let now = Cell::new(1u64);
|
||||
let settle = |g: &mut Ground, a: DVec3, b: DVec3| {
|
||||
let mut r = g.trace(7, a, b, false, now.get());
|
||||
for _ in 0..8 {
|
||||
if !matches!(r, world::Sweep::Wait { .. } | world::Sweep::Unknown { .. }) {
|
||||
break;
|
||||
}
|
||||
let q = g.world.take_wanted(now.get());
|
||||
g.world.serve(&world, q, now.get());
|
||||
now.set(now.get() + 1);
|
||||
r = g.trace(7, a, b, false, now.get());
|
||||
}
|
||||
r
|
||||
};
|
||||
assert_eq!(settle(&mut g, upper_a, upper_b), world::Sweep::Clear);
|
||||
// The same flight and block, the other half of the slab: not the old Clear, a new question.
|
||||
now.set(now.get() + 1);
|
||||
let r = g.trace(7, lower_a, lower_b, false, now.get());
|
||||
assert!(matches!(r, world::Sweep::Wait { .. }), "old Clear reused through the slab: {r:?}");
|
||||
let r = settle(&mut g, lower_a, lower_b);
|
||||
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 0], .. }), "{r:?}");
|
||||
// A repeat of the same step is served from memory and agrees with a fresh ask.
|
||||
let r2 = g.trace(7, lower_a, lower_b, false, now.get() + 1);
|
||||
assert_eq!(r, r2);
|
||||
// The slab becomes a full cube: the section is read again, the old Clear is void.
|
||||
assert_eq!(settle(&mut g, upper_a, upper_b), world::Sweep::Clear, "the upper step is clear again over the slab");
|
||||
cube.set(true);
|
||||
g.world.changed(1, false, &[(0, 4, 0)]);
|
||||
now.set(now.get() + 1);
|
||||
let r = g.trace(7, upper_a, upper_b, false, now.get());
|
||||
assert!(!matches!(r, world::Sweep::Clear), "Clear survived the block's replacement: {r:?}");
|
||||
// Not the cache's data but the game's: the section is read and the ray asked again.
|
||||
let r = settle(&mut g, upper_a, upper_b);
|
||||
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 0], .. }), "{r:?}");
|
||||
// A late reply to a question asked before a change does not touch the new world.
|
||||
cube.set(false);
|
||||
let mut h = Ground::default();
|
||||
h.world.changed(1, false, &[]);
|
||||
h.world.sweep(upper_a, upper_b, false, 1);
|
||||
let q = h.world.take_wanted(1);
|
||||
h.world.serve(&world, q, 1);
|
||||
let r = h.trace(8, upper_a, upper_b, false, 2);
|
||||
assert!(matches!(r, world::Sweep::Wait { .. }), "{r:?}");
|
||||
let old = h.world.take_rays(2);
|
||||
assert_eq!(old.len(), 1);
|
||||
cube.set(true);
|
||||
h.world.changed(1, false, &[(0, 4, 0)]);
|
||||
h.world.serve_rays(&world, old);
|
||||
let r = h.trace(8, upper_a, upper_b, false, 3);
|
||||
assert!(!matches!(r, world::Sweep::Clear), "a late reply was applied to the new world: {r:?}");
|
||||
// The wait is bounded however often the question is asked again.
|
||||
let mut h = Ground::default();
|
||||
h.world.changed(1, false, &[]);
|
||||
h.world.sweep(upper_a, upper_b, false, 1);
|
||||
let q = h.world.take_wanted(1);
|
||||
h.world.serve(&world, q, 1);
|
||||
let mut last = world::Sweep::Clear;
|
||||
for i in 0..60u64 {
|
||||
let dy = DVec3::new(0.0, 0.001 * i as f64, 0.0);
|
||||
last = h.trace(9, lower_a + dy, lower_b + dy, false, 2 + i);
|
||||
}
|
||||
assert!(matches!(last, world::Sweep::Hit { .. }), "a flight changing course every tick hangs: {last:?}");
|
||||
}
|
||||
|
||||
// #64: heights known are old when blocks change, a chunk is replaced, or time passes; they are asked again
|
||||
#[test]
|
||||
fn land_heights_follow_world_changes_and_age() {
|
||||
@@ -1206,7 +1334,15 @@ fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
|
||||
let stale = Some(Fix { pos: DVec3::new(-60.0, 100.0, 80.0), vel: DVec3::ZERO, tick: 90, seeker: false });
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: stale, seen: 0 });
|
||||
sim.tick = 100;
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
// The world is read: open air between them is a confirmed line of sight.
|
||||
let air = Blocks(|_, y, _| Some(y < 64));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..16 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), at, t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&air, q, t);
|
||||
}
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
(sim.missiles[0].vel, sim.missiles[0].seen)
|
||||
};
|
||||
let (v, seen) = near(DVec3::new(40.0, 100.0, 70.0));
|
||||
@@ -1221,7 +1357,7 @@ fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 90, seeker: false }), seen: 0 });
|
||||
let hill = Blocks(|x, y, z| Some(y < 64 || ((10..14).contains(&x) && (0..40).contains(&z) && y < 300)));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..6 {
|
||||
for t in 0..16 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(40.0, 100.0, 70.0), t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&hill, q, t);
|
||||
@@ -1241,6 +1377,114 @@ fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
|
||||
assert!(sim.missiles.is_empty() && sim.tally.expired == 1, "{:?}", sim.tally);
|
||||
}
|
||||
|
||||
// #75: unread blocks are not a line of sight; out of sight the seeker coasts on its last estimate whole
|
||||
#[test]
|
||||
fn seeker_needs_confirmed_sight_and_coasts_on_its_last_estimate() {
|
||||
let setup = |target_at: DVec3, tv: DVec3, fix: Option<Fix>| {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Shahed, target_at, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
|
||||
sim.drones[0].vel = tv;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix, seen: 0 });
|
||||
sim.tick = 100;
|
||||
sim
|
||||
};
|
||||
let at = DVec3::new(0.0, 100.0, 70.0);
|
||||
// Cold world: nothing read, nothing locked, however close the target is.
|
||||
let mut sim = setup(DVec3::new(20.0, 100.0, 70.0), DVec3::ZERO, None);
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
assert!(sim.missiles.iter().all(|m| m.seen == 0 && m.fix.is_none()), "an unread world was taken for line of sight");
|
||||
// Warm open air: locked.
|
||||
let air = Blocks(|_, y, _| Some(y < 64));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..16 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), at, t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&air, q, t);
|
||||
}
|
||||
let mut sim = setup(at, DVec3::new(0.5, 0.0, 0.0), None);
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
let f = sim.missiles[0].fix.expect("no lock in clear air");
|
||||
assert!(sim.missiles[0].seen == 100 && f.seeker && f.tick == 100 && f.vel == DVec3::new(0.5, 0.0, 0.0), "{f:?}");
|
||||
// Then a wall comes between them and the target manoeuvres: the estimate stays as it was, pos, vel and tick.
|
||||
let wall = Blocks(|x, y, z| Some(y < 64 || (x == 5 && y < 200 && z.abs() < 200)));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..16 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(10.0, 100.0, 70.0), t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&wall, q, t);
|
||||
}
|
||||
let last = Fix { pos: at, vel: DVec3::ZERO, tick: 100, seeker: true };
|
||||
let mut sim = setup(DVec3::new(10.0, 100.0, 70.0), DVec3::new(2.0, 0.0, 0.0), Some(last));
|
||||
sim.missiles[0].seen = 100;
|
||||
sim.tick = 101;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
let f = sim.missiles[0].fix.expect("the estimate was dropped");
|
||||
assert!(f.pos == last.pos && f.vel == DVec3::ZERO && f.tick == 100, "truth leaked through the wall: {f:?}");
|
||||
}
|
||||
|
||||
// #85: the graphics settings never reach the combat: the same seed and commands play out the same
|
||||
#[test]
|
||||
fn graphics_settings_do_not_change_combat_randomness_or_ids() {
|
||||
// The issue's case: one explosion, only the shader differs.
|
||||
let after_blast = |shader: bool| {
|
||||
let mut sim = Sim::new(12345);
|
||||
sim.shader = shader;
|
||||
sim.fireball(DVec3::new(0.0, 80.0, 0.0), 4.0, 11);
|
||||
let id = sim.id();
|
||||
let aim = sim.aim_error(DroneKind::Shahed);
|
||||
(id, aim)
|
||||
};
|
||||
assert_eq!(after_blast(true), after_blast(false), "a fireball spent entity ids or combat randomness");
|
||||
// A shot-down drone, with its debris and sound: no more than the same shot-down without them.
|
||||
let after_kill = |shader: bool, effects: u32| {
|
||||
let mut sim = Sim::new(99);
|
||||
sim.shader = shader;
|
||||
let mut out = Out::default();
|
||||
for i in 0..effects {
|
||||
sim.fireball(DVec3::new(f64::from(i), 80.0, 0.0), 4.0, 11);
|
||||
}
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(900.0, 80.0, 0.0));
|
||||
sim.shoot_down(0, 0, None, &mut out);
|
||||
let id = sim.id();
|
||||
(sim.drones[0].id, id, sim.aim_error(DroneKind::Shahed))
|
||||
};
|
||||
assert_eq!(after_kill(true, 0), after_kill(false, 0));
|
||||
assert_eq!(after_kill(false, 3), after_kill(true, 9));
|
||||
// A whole raid: the combat's outcome is the same with the shader on and off (and drawn or not).
|
||||
let play = |shader: bool, drawn: bool| {
|
||||
let mut sim = Sim::new(31);
|
||||
sim.shader = shader;
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for (i, kind) in [SamKind::Patriot, SamKind::Pantsir, SamKind::Gepard, SamKind::Patriot].into_iter().enumerate() {
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, kind, DVec3::new(i as f64 * 25.0 - 40.0, 70.0, 10.0 * i as f64), 0.0));
|
||||
}
|
||||
sim.raid(DroneKind::Shahed, DVec3::new(0.0, 70.0, 0.0), 14, 500.0, &mut out);
|
||||
// Whatever the settings, the world answers the same: nothing but the land is asked for here.
|
||||
let mut trace = Vec::new();
|
||||
for _ in 0..1500 {
|
||||
if !drawn {
|
||||
out = Out::default();
|
||||
}
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if out.models.len() > 20_000 {
|
||||
out = Out::default();
|
||||
}
|
||||
trace.push((sim.kills, sim.hits, sim.launched, sim.drones.len(), sim.missiles.len()));
|
||||
}
|
||||
let drones: Vec<_> = sim.drones.iter().map(|d| (d.id, d.pos.to_array().map(f64::to_bits))).collect();
|
||||
let missiles: Vec<_> = sim.missiles.iter().map(|m| (m.id, m.pos.to_array().map(f64::to_bits))).collect();
|
||||
let sams: Vec<_> = sim.sams.iter().map(|s| (s.id, s.kills)).collect();
|
||||
(trace, drones, missiles, sams, sim.crashes, sim.tally.blocked, sim.tally.expired)
|
||||
};
|
||||
let (a, b) = (play(true, true), play(false, false));
|
||||
assert!(a.0.last().is_some_and(|t| t.2 > 0), "nothing was launched: the test proves nothing");
|
||||
assert_eq!(a, b, "the combat differs with the graphics");
|
||||
}
|
||||
|
||||
// #69: many separate strikes: the list, what is drawn and the lights are bounded; the rest end properly
|
||||
#[test]
|
||||
fn mass_strikes_keep_fires_effects_and_lights_bounded() {
|
||||
|
||||
@@ -631,7 +631,7 @@ impl Sam {
|
||||
impl Sim {
|
||||
/// Draws unit `i`: its vehicle, its launcher or turret where it points, its antennas turning.
|
||||
pub(super) fn draw_sam(&mut self, i: usize, out: &mut Out) {
|
||||
draw_sam_model(&self.sams[i], &mut self.rng, out);
|
||||
draw_sam_model(&self.sams[i], &mut self.vis, out);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ struct RayEntry {
|
||||
b: DVec3,
|
||||
state: Exact,
|
||||
asked: u64,
|
||||
/// When this wait began: re-asking for a changed step does not restart the clock (#73).
|
||||
first: u64,
|
||||
/// No usable exact answer (timed out, or no verdict): the solid cube of the sections stands.
|
||||
cube: bool,
|
||||
}
|
||||
|
||||
/// The game's verdict on an exact ray, as `render.rays` gives it.
|
||||
@@ -128,8 +132,28 @@ pub struct RayReply {
|
||||
/// Ticks an exact ray may take to be answered before the block counts as the solid cube the sections say.
|
||||
const RAY_WAIT: u64 = 30;
|
||||
/// Exact rays asked in one tick (the game's budget is 256).
|
||||
/// How many steps long the exact ray is asked.
|
||||
const RAY_REACH: f64 = 3.0;
|
||||
const RAYS_PER_TICK: usize = 128;
|
||||
|
||||
/// Whether step `a`..`b` lies on the asked segment `a0`..`b0` (same line, same way, inside it, give or take a
|
||||
/// hair): the fractions of its ends along the asked one.
|
||||
fn within(a0: DVec3, b0: DVec3, a: DVec3, b: DVec3) -> Option<(f64, f64)> {
|
||||
const EPS: f64 = 1e-3;
|
||||
let d0 = b0 - a0;
|
||||
let l2 = d0.length_squared();
|
||||
if l2 < 1e-12 {
|
||||
return (a.distance(a0) < EPS && b.distance(b0) < EPS).then_some((0.0, 1.0));
|
||||
}
|
||||
let (sa, sb) = ((a - a0).dot(d0) / l2, (b - a0).dot(d0) / l2);
|
||||
let len = l2.sqrt();
|
||||
let tol = EPS / len;
|
||||
if sa < -tol || sb > 1.0 + tol || sb <= sa || a.distance(a0 + d0 * sa) > EPS || b.distance(a0 + d0 * sb) > EPS {
|
||||
return None;
|
||||
}
|
||||
Some((sa.max(0.0), sb.min(1.0)))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RayDesk {
|
||||
next: u32,
|
||||
@@ -215,6 +239,7 @@ impl WorldCache {
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
if epoch != self.epoch {
|
||||
self.rays = RayDesk { next: self.rays.next, ..RayDesk::default() };
|
||||
if self.epoch != 0 {
|
||||
self.secs.clear();
|
||||
self.urgent.clear();
|
||||
@@ -227,6 +252,7 @@ impl WorldCache {
|
||||
}
|
||||
if overflow || unexplained {
|
||||
self.changed_all = true;
|
||||
self.drop_rays(|_| true);
|
||||
}
|
||||
if overflow {
|
||||
for e in self.secs.values_mut() {
|
||||
@@ -234,6 +260,10 @@ impl WorldCache {
|
||||
}
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
if !sections.is_empty() {
|
||||
let set: HashSet<Sec> = sections.iter().copied().collect();
|
||||
self.drop_rays(|b| set.contains(&sec_of(b[0], b[1], b[2])));
|
||||
}
|
||||
for s in sections {
|
||||
self.note_column(s.0, s.2);
|
||||
if let Some(e) = self.secs.get_mut(s) {
|
||||
@@ -272,6 +302,7 @@ impl WorldCache {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.drop_rays(|b| set.contains(&(b[0] >> 4, b[2] >> 4)));
|
||||
self.stats.invalidated += n;
|
||||
self.stats.columns_changed += columns.len() as u64;
|
||||
}
|
||||
@@ -280,6 +311,10 @@ impl WorldCache {
|
||||
pub fn touched(&mut self, at: DVec3, radius: f64) {
|
||||
let lo = sec_of((at.x - radius).floor() as i32, (at.y - radius).floor() as i32, (at.z - radius).floor() as i32);
|
||||
let hi = sec_of((at.x + radius).floor() as i32, (at.y + radius).floor() as i32, (at.z + radius).floor() as i32);
|
||||
self.drop_rays(|b| {
|
||||
let s = sec_of(b[0], b[1], b[2]);
|
||||
(lo.0..=hi.0).contains(&s.0) && (lo.1..=hi.1).contains(&s.1) && (lo.2..=hi.2).contains(&s.2)
|
||||
});
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
@@ -462,33 +497,70 @@ impl WorldCache {
|
||||
pub fn narrow(&mut self, key: u32, hit: Sweep, a: DVec3, b: DVec3, now: u64) -> Sweep {
|
||||
let Sweep::Hit { t, block, .. } = hit else { return hit };
|
||||
let k = (key, block);
|
||||
let Some(e) = self.rays.entries.get_mut(&k) else {
|
||||
if self.rays.entries.len() >= 4096 {
|
||||
let mut first = now;
|
||||
if let Some(e) = self.rays.entries.get_mut(&k) {
|
||||
if e.cube {
|
||||
return hit;
|
||||
}
|
||||
self.rays.next = self.rays.next.wrapping_add(1);
|
||||
let id = self.rays.next;
|
||||
self.rays.entries.insert(k, RayEntry { id, a, b, state: Exact::Pending, asked: now });
|
||||
self.rays.by_id.insert(id, k);
|
||||
self.rays.out.push((id, a, b));
|
||||
return Sweep::Wait { t };
|
||||
};
|
||||
if e.state == Exact::Pending && now.saturating_sub(e.asked) > RAY_WAIT {
|
||||
e.state = Exact::Hit { point: a + (b - a) * t, normal: [0; 3], block };
|
||||
}
|
||||
match e.state {
|
||||
Exact::Pending => Sweep::Wait { t },
|
||||
Exact::Clear => Sweep::Clear,
|
||||
Exact::Hit { point, normal, block } => {
|
||||
let d = b - a;
|
||||
let f = ((point - a).dot(d) / d.length_squared().max(1e-12)).max(0.0);
|
||||
if f > 1.0 {
|
||||
// The shape's face is beyond this step: nothing is met yet.
|
||||
Sweep::Clear
|
||||
} else {
|
||||
Sweep::Hit { t: f, point, normal, block }
|
||||
// An answer is for the segment it was asked of: it serves this step only if the step lies on it.
|
||||
if let Some((sa, sb)) = within(e.a, e.b, a, b) {
|
||||
match e.state {
|
||||
Exact::Pending => {
|
||||
if now.saturating_sub(e.first) > RAY_WAIT {
|
||||
e.cube = true;
|
||||
return hit;
|
||||
}
|
||||
return Sweep::Wait { t };
|
||||
}
|
||||
Exact::Clear => return Sweep::Clear,
|
||||
Exact::Hit { point, normal, block } => {
|
||||
let d0 = e.b - e.a;
|
||||
let f_old = ((point - e.a).dot(d0) / d0.length_squared().max(1e-12)).clamp(0.0, 1.0);
|
||||
let f = (f_old - sa) / (sb - sa).max(1e-12);
|
||||
if f > 1.0 {
|
||||
// The shape's face is beyond this step: nothing is met yet.
|
||||
return Sweep::Clear;
|
||||
}
|
||||
let f = f.max(0.0);
|
||||
return Sweep::Hit { t: f, point: a + (b - a) * f, normal, block };
|
||||
}
|
||||
}
|
||||
}
|
||||
// A different step: the old answer says nothing of it. Ask again; an unanswered wait keeps its clock.
|
||||
if e.state == Exact::Pending {
|
||||
first = e.first;
|
||||
if now.saturating_sub(first) > RAY_WAIT {
|
||||
e.cube = true;
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
let old = e.id;
|
||||
self.rays.by_id.remove(&old);
|
||||
self.rays.out.retain(|r| r.0 != old);
|
||||
} else if self.rays.entries.len() >= 4096 {
|
||||
return hit;
|
||||
}
|
||||
self.rays.next = self.rays.next.wrapping_add(1);
|
||||
let id = self.rays.next;
|
||||
// Asked over a longer stretch than the step: a flight held at the face tries again from there with a
|
||||
// step of about the same line, which the answer then covers. What lies beyond the step is mapped back
|
||||
// when it is used (a face past the step is no contact).
|
||||
let far = a + (b - a) * RAY_REACH;
|
||||
self.rays.entries.insert(k, RayEntry { id, a, b: far, state: Exact::Pending, asked: now, first, cube: false });
|
||||
self.rays.by_id.insert(id, k);
|
||||
self.rays.out.push((id, a, far));
|
||||
Sweep::Wait { t }
|
||||
}
|
||||
|
||||
/// Forgets the exact answers (and questions) for blocks where `gone` says the geometry may have changed:
|
||||
/// a late reply to a forgotten question is dropped, the next step asks anew (#73).
|
||||
fn drop_rays(&mut self, gone: impl Fn(&[i32; 3]) -> bool) {
|
||||
let dead: Vec<(u32, [i32; 3])> = self.rays.entries.keys().filter(|k| gone(&k.1)).copied().collect();
|
||||
for k in dead {
|
||||
if let Some(e) = self.rays.entries.remove(&k) {
|
||||
self.rays.by_id.remove(&e.id);
|
||||
self.rays.out.retain(|r| r.0 != e.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,12 +584,16 @@ impl WorldCache {
|
||||
for r in replies {
|
||||
let Some(k) = self.rays.by_id.get(&r.id).copied() else { continue };
|
||||
let Some(e) = self.rays.entries.get_mut(&k) else { continue };
|
||||
if e.cube {
|
||||
continue;
|
||||
}
|
||||
e.state = if r.clear {
|
||||
Exact::Clear
|
||||
} else if r.hit {
|
||||
Exact::Hit { point: e.a + (e.b - e.a) * r.fraction, normal: r.normal, block: r.block }
|
||||
} else {
|
||||
// No verdict: the solid cube the sections said.
|
||||
e.cube = true;
|
||||
Exact::Hit { point: e.a, normal: [0; 3], block: k.1 }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +113,27 @@ impl WaveConfig {
|
||||
let aim = Aim::ALL.iter().position(|a| *a == self.aim).unwrap_or(0);
|
||||
format!("{} {approach} {} {aim} {}", counts.join(","), self.dist, self.preset)
|
||||
}
|
||||
/// Whether `w` are words `parse` would read whole: an error says which is wrong (#86).
|
||||
pub fn check(&self, w: &[&str]) -> Result<(), String> {
|
||||
if let Some(c) = w.first() {
|
||||
if c.split(',').take(KINDS.len()).any(|v| v.parse::<u32>().is_err()) {
|
||||
return Err(format!("число по типам: «{c}»"));
|
||||
}
|
||||
}
|
||||
if w.get(1).is_some_and(|v| v.parse::<usize>().ok().is_none_or(|i| i >= Approach::ALL.len())) {
|
||||
return Err(format!("подход: «{}»", w[1]));
|
||||
}
|
||||
if w.get(2).is_some_and(|v| v.parse::<f64>().ok().is_none_or(|d| !d.is_finite())) {
|
||||
return Err(format!("дальность: «{}»", w[2]));
|
||||
}
|
||||
if w.get(3).is_some_and(|v| v.parse::<usize>().ok().is_none_or(|i| i >= Aim::ALL.len())) {
|
||||
return Err(format!("цель налёта: «{}»", w[3]));
|
||||
}
|
||||
if w.get(4).is_some_and(|v| v.parse::<usize>().is_err()) {
|
||||
return Err(format!("набор: «{}»", w[4]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Reads `text`'s words; what does not parse keeps its value.
|
||||
pub fn parse(&mut self, w: &[&str]) {
|
||||
if let Some(c) = w.first() {
|
||||
|
||||
@@ -233,6 +233,9 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
|
||||
| #68: шум облаков по Y — от низа слоя, а не от камеры; шкалы тумана 170→128, дыма 22→32, луж 0.45/1.3→0.5/1.25 делят период обёртки 65536/256; тест `noise_scales_divide_the_camera_wrap` | готово | в рабочем дереве форка |
|
||||
| #70: `Game::sky_darken` — всегда реальное время мира (ИИ, спавн, блоки, статистика); `shown_sky_darken` (время реплея) — только тени сущностей и свет глаза; небо/луна/цвета остаются по показанному времени | готово (собрано, игровой проверки день/ночь реплея нет) | в рабочем дереве форка |
|
||||
| Объёмный дым и следы ракет (#54, п. 8 плана): API уровня 14 `render.set-smoke`, сетка дыма 128×64×128 по 3 блока (`gfx/hd/smoke.rs`, `smoke_grid.wgsl`), марш в проходе тумана, «туннель» в тумане и облаках, `HORIZON_HD_SMOKE=0`; мод: `airdef-mod/src/smoke.rs` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено |
|
||||
| Цветные тени (план 6): ближний каскад без листвы в глубине + карта цвета 1024² (`Rgba16Float`, `shadow_tint_pass`): стекло/лёд умножают свет на цвет, листва пропускает ×0.55 зелёного; в альфе расстояние до источника света (min-blend); `fs_light` берёт 8 отсчётов (`shadow_tint`); `HORIZON_HD_COLOR_SHADOW=0` — выкл. | готово; радуга от цветного стекла видна в игре, листва — слабым зеленоватым пятном; без A/B с выключенным | в рабочем дереве форка |
|
||||
| Рябь от дождя на воде (остаток плана 7): кольца в сетке 1.6 блока (`ripple_gradient`, `water.wgsl`), уровень дождя в `sky_extra.w`; `HORIZON_HD_RAIN_RIPPLES=0` — выкл. | готово; в игре едва различимы дуги в бликах (частицы дождя закрывают), не настраивалось | в рабочем дереве форка |
|
||||
| Motion blur и DoF кинокамеры (план 9): `gfx/hd/film.rs` + `film.wgsl`, проход после TAA, только при `film_quality()`; `HORIZON_HD_MOTION_BLUR=0`, `HORIZON_HD_DOF=0`, `HORIZON_HD_DOF_FOCUS`, `HORIZON_HD_DOF_STRENGTH`, `HORIZON_HD_FILM_QUALITY=1` (принудительно, для проб) | DoF виден в игре (до правки стоил 14–17 мс); motion blur в игре не подтверждён; правка стоимости DoF (планарная глубина, 24 отсчёта, радиус 0.7%) собрана и прошла тесты, но глазами не проверена | в рабочем дереве форка |
|
||||
|
||||
## План (по порядку) и ТЗ
|
||||
|
||||
@@ -253,10 +256,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
|
||||
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Перистые и облака в воде сделаны (см. «Облака, перистые, туман по рельефу»).
|
||||
5. **Блики и материалы** — СДЕЛАНО (см. «Материалы, мокрое, ветер»). Исходное ТЗ: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
|
||||
мода), мокрые поверхности и лужи в дождь.
|
||||
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
|
||||
7. **Ветер** — СДЕЛАНО (рябь от дождя на воде осталась). Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
|
||||
6. **PCSS** и цветные тени через стекло/листву — СДЕЛАНО (см. «Цветные тени, рябь, кино-пост»). Не сделано: цвет в лучах тумана и в тени под водой, цветная тень дальнего каскада.
|
||||
7. **Ветер** и рябь от дождя — СДЕЛАНО. Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
|
||||
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
|
||||
9. Опционально: motion blur, DoF (для кинокамеры повторов).
|
||||
9. Motion blur, DoF для кинокамеры — СДЕЛАНО в коде (см. ниже), проверено частично.
|
||||
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
|
||||
отражения, затем GI. Отдельный пресет, HD остаётся.
|
||||
|
||||
@@ -364,3 +367,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
|
||||
- **Захват** (`render.capture-start/stop/status`, `game/film.rs`, `Renderer::capture_*`): часы игры идут ровно `1/fps` секунды за кадр (тики, интерполяция, `tick` мода), кадр копируется с GPU через очередь из ≤3 буферов и уходит в поток-писатель → `ffmpeg` (libx264, иначе h264_videotoolbox; иначе PNG-последовательность). Звук: микшер OpenAL-подобный (`audio.rs`) работает офлайн (`film_begin/film_mix`), слышны только звуки мода, ориентация — по камере; дорожка вмуксивается AAC. После склейки рисуются `warmup` кадров без записи, чтобы сошлись TAA и объёмы. Размер: offscreen-игра ресайзится, окно просится (`request_inner_size`).
|
||||
- Мод-HUD (`set-hud`) теперь рисуется и при `set-main-view` (титры, леттербокс фильма); ванильный HUD по-прежнему скрыт.
|
||||
- Режиссура: `airdef-mod/src/film.rs` (план по записи, варианты ракурсов, проверка рельефа по высотам `Ground::land_state`, ускорение/замедление времени).
|
||||
|
||||
## Цветные тени, рябь, кино-пост: что знать преемнику
|
||||
|
||||
- **Цветные тени.** Ближний каскад (c = 0) рисуется `fs_shadow_near` (листва `MAT_LEAVES` отброшена), потом проход `hd shadow colour` (`Hd::shadow_tint_pass`, цель `Shadows::tint`, очистка в белое) рисует листву (`shadow_tint_leaves`, твёрдые квады, `SHADOW_SOLID`) и полупрозрачные квады секций (`shadow_tint_glass`, по отсортированным индексам; вода не рисуется) тем же `fs_shadow_tint`: rgb умножаются (blend `Dst*src`), альфа = `(расстояние от света − 320)/160` с blend Min — ближайший такой квад. Приёмник в `shadow_tint` (`light.wgsl`, привязка 22) окрашивается, если квад ближе к свету, чем он; 8 точек Vogel радиусом 0.4 блока размывают листву в «пятна». Дальний каскад по-прежнему рисует листву полностью (на границе 36 блоков шов: ближе листва светлее). Цвет не доходит до лучей тумана, воды и дальнего каскада. Цена: `hd shadow colour` 0.25–0.35 мс (Mac, малая сцена).
|
||||
- **Рябь.** `ripple_gradient` берёт часы воды (`water.y`, часы фильма): частоты — целые циклы на 600 с, сетка 160 клеток на 256 блоков, поэтому рисунок не прыгает на обороте. Гаснет по размеру пикселя, только под открытым небом (`nt.a`). Амплитуда 0.012 не подбиралась.
|
||||
- **Кино-пост.** `Hd::film` между `taa` и `finish` (`HdSource::Film`), только при `film_quality()`; цели создаются при первом фильме. Motion blur — только от движения камеры (глубина + `prev_view_proj`, затвор 180°, мёртвая зона 1–2.5 px из-за джиттера; движущиеся объекты не размываются; нет после склейки/телепорта: `taa.w`). DoF — диск 24 отсчёта, фокус авто (гармоническое среднее 9 глубин у центра) или `HORIZON_HD_DOF_FOCUS`. Детерминизм: единственное время в шейдере — `water.y` (часы фильма) для шума. Проверять на реальном фильме `/airdef replay film`; `HORIZON_HD_FILM_QUALITY=1` принудительно включает качество фильма для проб. DoF в игре был слишком сильным и дорогим; параметры уменьшены без повторного снимка. Motion blur на снимке (поворот 40° за 1.5 с) не разобран.
|
||||
- Из Photon (MIT) ничего нового не переносилось: THIRD_PARTY_PHOTON.md не менялся.
|
||||
|
||||
@@ -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/33] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/34] 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/33] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/34] 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/33] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/34] 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/33] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/34] 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/33] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/34] 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/33] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/34] 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/33] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/34] 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/33] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/34] 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/33] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/34] 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/33] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/34] 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/33] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/34] 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/33] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/34] 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/33] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/34] 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/33] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/34] 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/33] HD: GPU time per pass in the F3 timing list,
|
||||
Subject: [PATCH 15/34] 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/33] HD: shadows draw only sun-facing quads and skip buried
|
||||
Subject: [PATCH 16/34] 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/33] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/34] 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/33] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
Subject: [PATCH 18/34] 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/33] HD water: its own pass (colour, normal, depth), the
|
||||
Subject: [PATCH 19/34] 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/33] HD water: no vanilla texture on the surface (the biome
|
||||
Subject: [PATCH 20/34] 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/33] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
Subject: [PATCH 21/34] 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/33] HD water: a darker blue, a little clearer, ripples a
|
||||
Subject: [PATCH 22/34] 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/33] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
Subject: [PATCH 23/34] 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/33] Mods: world queries for simulations (render.query-rays,
|
||||
Subject: [PATCH 24/34] 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/33] HD fixes: rain level from the world not from rain
|
||||
Subject: [PATCH 25/34] 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/33] HD split into modules (gfx/hd/: one Rust file and one
|
||||
Subject: [PATCH 26/34] 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/33] HD clouds: noise scales that divide the camera's
|
||||
Subject: [PATCH 27/34] 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/33] HD: volumetric smoke and missile trails with clear
|
||||
Subject: [PATCH 28/34] 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/33] HD clouds composited by depth so terrain and vehicles
|
||||
Subject: [PATCH 29/34] 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/33] HD smoke: young trails drawn at least as wide as a grid
|
||||
Subject: [PATCH 30/34] 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/33] Mods: render.set-main-view (API level 15) renders the
|
||||
Subject: [PATCH 31/34] 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/33] Spot lights for mods (render.set-spot-lights, API level
|
||||
Subject: [PATCH 32/34] 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/33] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
Subject: [PATCH 33/34] 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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
Запуск: python3 tools/gen_models.py
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
@@ -1301,20 +1302,355 @@ ITEMS = [
|
||||
]
|
||||
|
||||
|
||||
def item_icon(color, seed):
|
||||
rnd = random.Random(seed)
|
||||
im = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
|
||||
for y in range(2, 14):
|
||||
for x in range(4, 12):
|
||||
im.putpixel((x, y), tuple(clamp(c + rnd.randint(-10, 10)) for c in color) + (255,))
|
||||
for x in range(5, 11):
|
||||
im.putpixel((x, 3), (230, 230, 230, 255))
|
||||
im.putpixel((7, 6), (255, 60, 60, 255))
|
||||
im.putpixel((8, 6), (255, 60, 60, 255))
|
||||
for y in range(13, 16):
|
||||
im.putpixel((7, y), (40, 40, 40, 255))
|
||||
im.putpixel((8, y), (40, 40, 40, 255))
|
||||
return im
|
||||
# Иконки 16×16 без сглаживания. Каждый предмет рисуется своей функцией: категория задаёт основу
|
||||
# (машина ПВО, пуск с пламенем, РЛС, склад, прожектор, целеуказатель, стрелки, сворачивание),
|
||||
# внутри категории — мини-силуэт типа. Цвет предмета — только заливка, форма различает предметы.
|
||||
class _Icon:
|
||||
def __init__(self, color, seed):
|
||||
self.rnd = random.Random(seed)
|
||||
self.im = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
|
||||
self.pal = {
|
||||
"b": tuple(color),
|
||||
"d": tuple(clamp(v * 0.6) for v in color),
|
||||
"l": tuple(clamp(v + 45) for v in color),
|
||||
"k": (30, 30, 34), "r": (255, 60, 60), "o": (255, 150, 40),
|
||||
"y": (255, 232, 120), "w": (236, 236, 236),
|
||||
}
|
||||
|
||||
def px(self, x, y, key):
|
||||
if 0 <= x < 16 and 0 <= y < 16:
|
||||
c = self.pal[key]
|
||||
if key in ("b", "d", "l"):
|
||||
c = tuple(clamp(v + self.rnd.randint(-6, 6)) for v in c)
|
||||
self.im.putpixel((x, y), c + (255,))
|
||||
|
||||
def rect(self, x0, y0, x1, y1, key):
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
self.px(x, y, key)
|
||||
|
||||
def line(self, x0, y0, x1, y1, key):
|
||||
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||||
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
|
||||
err = dx + dy
|
||||
while True:
|
||||
self.px(x0, y0, key)
|
||||
if x0 == x1 and y0 == y1:
|
||||
break
|
||||
e2 = 2 * err
|
||||
if e2 >= dy:
|
||||
err += dy
|
||||
x0 += sx
|
||||
if e2 <= dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
def disc(self, cx, cy, r, key):
|
||||
for y in range(cy - r - 1, cy + r + 2):
|
||||
for x in range(cx - r - 1, cx + r + 2):
|
||||
if (x - cx) ** 2 + (y - cy) ** 2 <= r * r + 0.5 * r:
|
||||
self.px(x, y, key)
|
||||
|
||||
def ring(self, cx, cy, lo, hi, key):
|
||||
for y in range(16):
|
||||
for x in range(16):
|
||||
if lo <= (x - cx) ** 2 + (y - cy) ** 2 <= hi:
|
||||
self.px(x, y, key)
|
||||
|
||||
def poly(self, pts, key):
|
||||
# Выпуклый многоугольник: заливка по строкам между крайними пересечениями рёбер.
|
||||
n = len(pts)
|
||||
for y in range(min(p[1] for p in pts), max(p[1] for p in pts) + 1):
|
||||
xs = []
|
||||
for i in range(n):
|
||||
(x1, y1), (x2, y2) = pts[i], pts[(i + 1) % n]
|
||||
if y1 == y2:
|
||||
if y1 == y:
|
||||
xs += [x1, x2]
|
||||
elif min(y1, y2) <= y <= max(y1, y2):
|
||||
xs.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1))
|
||||
if xs:
|
||||
for x in range(math.ceil(min(xs) - 1e-9), math.floor(max(xs) + 1e-9) + 1):
|
||||
self.px(x, y, key)
|
||||
|
||||
|
||||
def _sam_base(c):
|
||||
c.rect(2, 8, 13, 11, "b")
|
||||
c.rect(3, 12, 4, 13, "k")
|
||||
c.rect(11, 12, 12, 13, "k")
|
||||
|
||||
|
||||
# Развёртывание ПВО: машина + пусковая установка.
|
||||
def _gepard(c): # ствольная турель, два длинных ствола
|
||||
_sam_base(c)
|
||||
c.disc(7, 6, 2, "b")
|
||||
c.line(9, 5, 15, 5, "d")
|
||||
c.line(9, 7, 15, 7, "d")
|
||||
|
||||
|
||||
def _pantsir(c): # ствольная турель с ракетными контейнерами сверху
|
||||
_sam_base(c)
|
||||
c.disc(7, 7, 2, "b")
|
||||
c.rect(4, 3, 5, 5, "l")
|
||||
c.rect(9, 3, 10, 5, "l")
|
||||
c.line(9, 6, 11, 6, "d")
|
||||
|
||||
|
||||
def _s400(c): # три вертикальных контейнера
|
||||
_sam_base(c)
|
||||
for x in (3, 6, 9):
|
||||
c.rect(x, 2, x + 1, 7, "b")
|
||||
c.rect(x, 2, x + 1, 2, "l")
|
||||
|
||||
|
||||
def _s500(c): # четыре вертикальных контейнера
|
||||
_sam_base(c)
|
||||
for x in (2, 5, 8, 11):
|
||||
c.rect(x, 2, x + 1, 7, "b")
|
||||
c.rect(x, 2, x + 1, 2, "l")
|
||||
|
||||
|
||||
def _patriot(c): # наклонный пусковой короб
|
||||
_sam_base(c)
|
||||
c.poly([(3, 8), (11, 3), (13, 5), (5, 10)], "b")
|
||||
c.line(7, 7, 9, 5, "d")
|
||||
|
||||
|
||||
def _buk(c): # наклонные направляющие с ракетой
|
||||
_sam_base(c)
|
||||
c.line(3, 8, 10, 2, "d")
|
||||
c.line(5, 9, 12, 3, "d")
|
||||
c.disc(12, 2, 1, "w")
|
||||
|
||||
|
||||
def _irist(c): # два наклонных контейнера
|
||||
_sam_base(c)
|
||||
c.poly([(3, 8), (7, 3), (9, 4), (5, 9)], "b")
|
||||
c.poly([(7, 9), (11, 3), (13, 4), (9, 10)], "b")
|
||||
|
||||
|
||||
def _nasams(c): # два горизонтальных контейнера
|
||||
_sam_base(c)
|
||||
c.rect(3, 2, 12, 3, "b")
|
||||
c.rect(3, 5, 12, 6, "b")
|
||||
c.line(3, 4, 12, 4, "d")
|
||||
|
||||
|
||||
def _tor(c): # сетка коротких вертикальных ячеек
|
||||
_sam_base(c)
|
||||
for x in (3, 6, 9):
|
||||
c.rect(x, 2, x + 1, 3, "b")
|
||||
c.rect(x, 5, x + 1, 6, "b")
|
||||
|
||||
|
||||
def _p1sun(c): # короб с мачтой
|
||||
_sam_base(c)
|
||||
c.rect(3, 5, 12, 7, "b")
|
||||
c.line(7, 1, 7, 4, "d")
|
||||
c.px(7, 1, "r")
|
||||
|
||||
|
||||
# Радиолокация и РЭБ.
|
||||
def _radar(c): # параболическая антенна на стойке
|
||||
c.disc(8, 5, 3, "l")
|
||||
c.line(8, 8, 8, 12, "d")
|
||||
c.rect(5, 13, 11, 14, "d")
|
||||
|
||||
|
||||
def _ew(c): # штырь-антенна с сигнальными дугами
|
||||
c.line(8, 6, 8, 14, "d")
|
||||
c.disc(8, 4, 1, "b")
|
||||
c.line(5, 2, 4, 4, "l")
|
||||
c.line(4, 4, 4, 6, "l")
|
||||
c.line(4, 6, 5, 8, "l")
|
||||
c.line(11, 2, 12, 4, "l")
|
||||
c.line(12, 4, 12, 6, "l")
|
||||
c.line(12, 6, 11, 8, "l")
|
||||
|
||||
|
||||
# Снабжение.
|
||||
def _depot(c): # штабель ящиков на поддоне
|
||||
c.rect(3, 8, 12, 13, "b")
|
||||
c.line(3, 10, 12, 10, "d")
|
||||
c.line(3, 12, 12, 12, "d")
|
||||
c.rect(2, 14, 13, 14, "d")
|
||||
c.rect(4, 4, 9, 7, "l")
|
||||
c.line(4, 6, 9, 6, "d")
|
||||
|
||||
|
||||
def _tzm(c): # грузовик с кабиной и кузовом
|
||||
c.rect(2, 7, 5, 11, "b")
|
||||
c.rect(3, 8, 4, 9, "w")
|
||||
c.rect(6, 4, 14, 11, "l")
|
||||
c.rect(3, 12, 4, 13, "k")
|
||||
c.rect(10, 12, 11, 13, "k")
|
||||
c.rect(13, 12, 14, 13, "k")
|
||||
|
||||
|
||||
# Прожектор.
|
||||
def _searchlight(c):
|
||||
c.rect(3, 4, 10, 8, "b")
|
||||
c.poly([(10, 4), (15, 1), (15, 11), (10, 8)], "y")
|
||||
c.line(6, 9, 6, 13, "d")
|
||||
c.rect(3, 14, 9, 14, "d")
|
||||
|
||||
|
||||
# Целеуказатель: кольцо с засечками.
|
||||
def _designator(c):
|
||||
c.ring(8, 8, 16, 26, "r")
|
||||
c.rect(7, 1, 8, 2, "r")
|
||||
c.rect(7, 13, 8, 14, "r")
|
||||
c.rect(1, 7, 2, 8, "r")
|
||||
c.rect(13, 7, 14, 8, "r")
|
||||
c.rect(7, 7, 8, 8, "r")
|
||||
|
||||
|
||||
# Пуски: огонь сзади, силуэт типа спереди.
|
||||
def _launch_base(c):
|
||||
c.rect(0, 7, 1, 8, "o")
|
||||
c.px(0, 7, "y")
|
||||
|
||||
|
||||
def _shahed(c): # треугольное крыло
|
||||
_launch_base(c)
|
||||
c.poly([(5, 2), (5, 13), (13, 7)], "b")
|
||||
|
||||
|
||||
def _geran3(c): # реактивный самолёт со стреловидным крылом
|
||||
_launch_base(c)
|
||||
c.rect(3, 7, 13, 8, "b")
|
||||
c.poly([(6, 7), (9, 2), (11, 2), (9, 7)], "d")
|
||||
c.poly([(6, 8), (9, 13), (11, 13), (9, 8)], "d")
|
||||
c.poly([(3, 7), (4, 3), (5, 3), (5, 7)], "d")
|
||||
|
||||
|
||||
def _lancet(c): # малый дрон с прямым крылом
|
||||
_launch_base(c)
|
||||
c.rect(3, 7, 12, 8, "b")
|
||||
c.rect(7, 2, 8, 13, "d")
|
||||
c.rect(3, 6, 4, 9, "d")
|
||||
|
||||
|
||||
def _kh101(c): # крылатая ракета: стреловидные крылья и хвост
|
||||
_launch_base(c)
|
||||
c.rect(3, 7, 12, 8, "b")
|
||||
c.poly([(12, 6), (15, 7), (15, 8), (12, 9)], "b")
|
||||
c.poly([(6, 7), (9, 3), (10, 3), (8, 7)], "d")
|
||||
c.poly([(6, 8), (9, 12), (10, 12), (8, 8)], "d")
|
||||
c.rect(2, 5, 3, 10, "d")
|
||||
|
||||
|
||||
def _kalibr(c): # крылатая ракета: тонкий корпус и четыре хвостовых киля
|
||||
_launch_base(c)
|
||||
c.rect(2, 7, 13, 8, "b")
|
||||
c.poly([(13, 6), (15, 7), (15, 8), (13, 9)], "b")
|
||||
c.rect(2, 4, 3, 5, "d")
|
||||
c.rect(2, 10, 3, 11, "d")
|
||||
|
||||
|
||||
def _kh22(c): # толстый корпус с большими дельта-крыльями
|
||||
_launch_base(c)
|
||||
c.rect(3, 6, 11, 9, "b")
|
||||
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "b")
|
||||
c.poly([(4, 6), (8, 2), (9, 2), (7, 6)], "d")
|
||||
c.poly([(4, 9), (8, 13), (9, 13), (7, 9)], "d")
|
||||
|
||||
|
||||
def _zircon(c): # гиперзвук: корпус с одним передним стрейком
|
||||
_launch_base(c)
|
||||
c.rect(2, 7, 12, 8, "b")
|
||||
c.poly([(12, 7), (15, 7), (12, 8)], "b")
|
||||
c.poly([(8, 7), (12, 3), (13, 3), (11, 7)], "d")
|
||||
|
||||
|
||||
def _iskander(c): # баллистическая: длинный корпус, конус, сопло
|
||||
_launch_base(c)
|
||||
c.rect(3, 7, 11, 8, "b")
|
||||
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "l")
|
||||
c.rect(2, 6, 2, 9, "d")
|
||||
|
||||
|
||||
def _oreshnik(c): # баллистическая с тремя головными частями
|
||||
_launch_base(c)
|
||||
c.rect(2, 6, 10, 9, "b")
|
||||
c.poly([(10, 5), (13, 6), (10, 7)], "l")
|
||||
c.poly([(10, 8), (13, 9), (10, 10)], "l")
|
||||
c.poly([(10, 6), (15, 7), (10, 8)], "b")
|
||||
|
||||
|
||||
def _kinzhal(c): # аэробаллистическая: корпус с треугольными килями
|
||||
_launch_base(c)
|
||||
c.rect(3, 7, 11, 8, "b")
|
||||
c.poly([(11, 6), (15, 7), (11, 9)], "b")
|
||||
c.poly([(4, 7), (7, 3), (8, 3), (6, 7)], "d")
|
||||
c.poly([(4, 8), (7, 12), (8, 12), (6, 8)], "d")
|
||||
|
||||
|
||||
def _mover(c): # четыре стрелки перемещения
|
||||
c.poly([(8, 0), (4, 5), (12, 5)], "b")
|
||||
c.rect(7, 4, 8, 7, "b")
|
||||
c.poly([(8, 15), (4, 10), (12, 10)], "b")
|
||||
c.rect(7, 8, 8, 11, "b")
|
||||
c.poly([(0, 8), (5, 4), (5, 12)], "b")
|
||||
c.rect(4, 7, 7, 8, "b")
|
||||
c.poly([(15, 8), (10, 4), (10, 12)], "b")
|
||||
c.rect(8, 7, 11, 8, "b")
|
||||
|
||||
|
||||
def _remover(c): # стрелка вниз над лотком: свёртывание
|
||||
c.poly([(8, 9), (3, 4), (13, 4)], "b")
|
||||
c.rect(7, 1, 8, 4, "b")
|
||||
c.rect(2, 11, 13, 13, "b")
|
||||
c.line(2, 11, 13, 11, "l")
|
||||
|
||||
|
||||
def _flare(c): # звезда осветительной ракеты на стержне
|
||||
c.disc(8, 7, 1, "y")
|
||||
c.line(8, 2, 8, 4, "y")
|
||||
c.line(8, 10, 8, 12, "y")
|
||||
c.line(3, 7, 5, 7, "y")
|
||||
c.line(11, 7, 13, 7, "y")
|
||||
c.line(4, 4, 5, 5, "w")
|
||||
c.line(11, 4, 10, 5, "w")
|
||||
c.line(4, 10, 5, 9, "w")
|
||||
c.line(11, 10, 10, 9, "w")
|
||||
c.rect(7, 12, 8, 15, "k")
|
||||
|
||||
|
||||
ICON_DRAW = {
|
||||
"designator": _designator,
|
||||
"launch_shahed": _shahed, "launch_geran3": _geran3, "launch_lancet": _lancet,
|
||||
"launch_kh101": _kh101, "launch_kalibr": _kalibr, "launch_kh22": _kh22,
|
||||
"launch_zircon": _zircon, "launch_iskander": _iskander, "launch_oreshnik": _oreshnik,
|
||||
"launch_kinzhal": _kinzhal,
|
||||
"deploy_patriot": _patriot, "deploy_gepard": _gepard, "deploy_pantsir": _pantsir,
|
||||
"deploy_s400": _s400, "deploy_s500": _s500, "deploy_irist": _irist,
|
||||
"deploy_nasams": _nasams, "deploy_buk": _buk, "deploy_tor": _tor, "deploy_p1sun": _p1sun,
|
||||
"deploy_radar": _radar, "deploy_ew": _ew,
|
||||
"deploy_depot": _depot, "deploy_tzm": _tzm,
|
||||
"deploy_light": _searchlight,
|
||||
"mover": _mover, "remover": _remover, "flare_gun": _flare,
|
||||
}
|
||||
|
||||
|
||||
def item_icon(name, color, seed):
|
||||
c = _Icon(color, seed)
|
||||
ICON_DRAW[name](c)
|
||||
# Контур по краю силуэта: тёмные предметы — светлым, светлые — тёмным, чтобы читались на обоих фонах.
|
||||
# Альфа-маска не меняется: контур перекрашивает только уже нарисованные пиксели.
|
||||
lum = 0.3 * color[0] + 0.59 * color[1] + 0.11 * color[2]
|
||||
edge = (160, 160, 160) if lum < 100 else (70, 70, 70) if lum > 190 else None
|
||||
if edge:
|
||||
src = c.im.copy()
|
||||
|
||||
def opaque(x, y):
|
||||
return 0 <= x < 16 and 0 <= y < 16 and src.getpixel((x, y))[3] > 0
|
||||
|
||||
for y in range(16):
|
||||
for x in range(16):
|
||||
if opaque(x, y) and not all(opaque(x + dx, y + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))):
|
||||
c.im.putpixel((x, y), edge + (255,))
|
||||
return c.im
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
@@ -1350,7 +1686,7 @@ def main():
|
||||
im = GT.burn(im, zlib.crc32(name.encode()), "glass" if base.startswith("glass") else "paint")
|
||||
im.save(os.path.join(a, "textures", "block", name + ".png"))
|
||||
for i, (name, title, color) in enumerate(ITEMS):
|
||||
item_icon(color, i).save(os.path.join(a, "textures", "item", name + ".png"))
|
||||
item_icon(name, color, zlib.crc32(name.encode())).save(os.path.join(a, "textures", "item", name + ".png"))
|
||||
write_json(os.path.join(a, "models", "item", name + ".json"),
|
||||
{"parent": "minecraft:item/generated", "textures": {"layer0": f"{NS}:item/{name}"}})
|
||||
write_json(os.path.join(a, "items", name + ".json"),
|
||||
|
||||