Compare commits

..

3 Commits

73 changed files with 2181 additions and 175 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 B

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

After

Width:  |  Height:  |  Size: 338 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 B

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 257 B

+201
View File
@@ -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}");
}
}
+70 -73
View File
@@ -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);
}
}
+3 -1
View File
@@ -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(())
}
+54 -24
View File
@@ -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);
}
}
+246 -2
View File
@@ -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() {
+1 -1
View File
@@ -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);
}
}
+99 -23
View File
@@ -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 }
};
}
+21
View File
@@ -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() {
+13 -3
View File
@@ -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
File diff suppressed because it is too large Load Diff
+351 -15
View File
@@ -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"),