diff --git a/README.md b/README.md index c4d6cdb..6dcfbc5 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,9 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо Радар игрока и мониторы показывают только то, что держат РЛС; в сводке видно «Вне видимости РЛС: N». Мёртвые зоны — ближе минимальной дальности ракет (Patriot 10 м, Панцирь 25, С-400 30, С-500 40). +**Сохранение.** Комплексы, объекты, их повреждения и настройки сохраняются отдельно для каждого мира (и для каждого +сервера); уничтоженный комплекс остаётся остовом и после перезахода, пока его не отремонтируют. + **Повреждения.** Взрыв рядом с комплексом повреждает его: он хуже поворачивается, хуже видит и попадает, дымит, ниже 35% горит. Уничтоженный остаётся горящим остовом, его боекомплект детонирует: чем больше ракет оставалось, тем мощнее взрыв и дольше вторичные (у Гепарда рвутся снаряды); соседние комплексы тоже могут @@ -105,6 +108,7 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо Команды: - `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар двумя секторами: «Герберы», шахеды, Герани-3, Х-101, «Калибры», Х-22, Циркон, «Искандер», «Кинжал», при 30+ — «Орешник»); - `/airdef replay [map|full|reel|stop]` — повтор последнего налёта; +- `/airdef import` — перенести в текущий мир комплексы и цели из старой версии мода (до 0.2.x они были общими для всех миров); - `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт; - `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`; - `/airdef deploy ` — поставить комплекс в 8 блоках перед собой; diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 4448ee7..6450d25 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -58,6 +58,10 @@ struct AirDef { replay: Option, /// The world drawn beyond the render distance (replays and cameras see far off). far: bool, + /// The world whose state is loaded (`game.world-id`), once one is. + world: Option>, + /// The systems' and objects' health as last saved, to save again when it changes. + health_mark: u64, /// The menu's page while it is open. menu: Option, /// Where the player is, for the sounds' delay and far variants. @@ -362,14 +366,18 @@ impl AirDef { for (kind, roles) in &self.sim.roles { text += &format!("roles {} {}\n", kind.key(), roles); } - if let Err(e) = storage::set("state", text.as_bytes()) { + if let Err(e) = storage::set(&self.state_key(), text.as_bytes()) { horizon_mod_sdk::warn(&format!("could not save: {e}")); } self.dirty = false; } fn load(&mut self) { - let Some(bytes) = storage::get("state") else { return }; + self.load_from(&self.state_key()); + } + + fn load_from(&mut self, key: &str) { + let Some(bytes) = storage::get(key) else { return }; let text = String::from_utf8_lossy(&bytes); for line in text.lines() { let w: Vec<&str> = line.split_whitespace().collect(); @@ -417,6 +425,42 @@ impl AirDef { } impl AirDef { + /// Where the open world's state is kept: a key of its own per world. + fn state_key(&self) -> String { + match self.world.as_ref().and_then(|w| w.as_ref()) { + Some(w) => { + // FNV-1a: storage keys are short, lowercase and plain. + let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3)); + format!("state-{h:016x}") + } + None => "state".into(), + } + } + + /// Another world opened: the last one's state saved, a clean simulation with this one's loaded. + fn open_world(&mut self, world: Option) { + if self.world.is_some() && self.dirty { + self.save(); + } + if self.replay.is_some() { + self.stop_replay(); + } + let shader = self.sim.shader; + let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37); + self.sim = Sim::new(seed); + self.sim.shader = shader; + self.ground = Ground::default(); + self.monitor = None; + self.grid = false; + self.grid_ids.clear(); + self.far = true; + self.world = Some(world); + self.load(); + self.apply_far(); + self.dirty = false; + self.health_mark = 0; + } + /// Distant terrain (48 chunks, coarse) while `far` is on: the raid's whole area has land, /// in the world and in the cameras. fn apply_far(&self) { @@ -640,7 +684,7 @@ impl AirDef { } } -const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off | hold on|off | roles [комплекс классы] | stats"; +const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | import | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off | hold on|off | roles [комплекс классы] | stats"; impl Mod for AirDef { fn init() -> Result<(Self, Registration), String> { @@ -657,6 +701,8 @@ impl Mod for AirDef { wave: wave::WaveConfig::default(), replay: None, far: true, + world: None, + health_mark: 0, menu: None, listener: DVec3::ZERO, pending: Vec::new(), @@ -673,7 +719,6 @@ impl Mod for AirDef { // Not Enter: it opens the chat. let _ = input::register_key("rp_pause", "Повтор: пауза", "key.keyboard.space"); let _ = input::register_key("rp_exit", "Повтор: выход", "key.keyboard.backspace"); - me.load(); input::claim_items(&ITEMS.map(String::from)); render::set_post(include_str!("../shaders/effects.wgsl")); me.sim.shader = true; @@ -683,6 +728,11 @@ impl Mod for AirDef { fn tick(&mut self, _tick: u64) { let Some(p) = player() else { return }; + // Each world keeps its own systems, objects and settings: loaded when it opens. + let world = game::world_id(); + if self.world.as_ref() != Some(&world) { + self.open_world(world); + } for event in input::poll() { match event { Event::UseStart(u) => self.use_item(&u.item.id), @@ -779,6 +829,18 @@ impl Mod for AirDef { } } } + // Damage is saved too: a destroyed system stays destroyed. + let mark = self + .sim + .sams + .iter() + .map(|s| (s.health * 1000.0) as u64) + .chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64)) + .fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h)); + if mark != self.health_mark { + self.health_mark = mark; + self.dirty = true; + } if self.dirty { self.save(); } @@ -988,6 +1050,13 @@ impl Mod for AirDef { self.launch_wave(&mut out)?; None } + ["import"] => { + // The state the mod kept before worlds had their own: brought into this world. + let before = self.sim.sams.len(); + self.load_from("state"); + self.dirty = true; + Some(format!("Перенесено комплексов из старой версии: {}", self.sim.sams.len() - before)) + } ["repair"] => { let n = self.sim.repair(); self.dirty = true; diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch index d8d5965..4f91e87 100644 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 1/8] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 1/9] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch index 2a68a45..6cb0c6f 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 2/8] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 2/9] cargo-horizon-mod: report particles and sounds Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch index 4e26c1a..007ba5b 100644 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 3/8] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 3/9] 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 diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch index 718441b..370eb33 100644 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 4/8] Mod models: draw far models as scaled impostors past the +Subject: [PATCH 4/9] Mod models: draw far models as scaled impostors past the far plane, cull at 4096 --- diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch index e67e7ac..ce1db24 100644 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 5/8] Mod cameras: render.set-views draws the world from other +Subject: [PATCH 5/9] 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 diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch index de9add4..9bc422c 100644 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 6/8] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 6/9] Mods: more fuel per tick and per event (AirDef plans raids) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch index f94aa5a..ad81800 100644 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 7/8] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 7/9] Mod cameras up to 1920x1080 (full-screen replays) Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch index 84a601c..3d477eb 100644 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 8/8] Mod cameras: distant terrain tiles, fog measured from the +Subject: [PATCH 8/9] Mod cameras: distant terrain tiles, fog measured from the camera, no impostors while one is shown Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch new file mode 100644 index 0000000..1ff3672 --- /dev/null +++ b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch @@ -0,0 +1,126 @@ +From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Thu, 29 Oct 2026 11:25:45 +0300 +Subject: [PATCH 9/9] Mods: game.world-id, so mods keep state per world (API + level 9) + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/game.rs | 1 + + crates/horizon-client/src/game/mods.rs | 3 +++ + crates/horizon-client/src/game/remote.rs | 4 ++++ + crates/horizon-mod-sdk/wit/horizon.wit | 4 ++++ + crates/horizon-mods/src/api_level.rs | 3 ++- + crates/horizon-mods/src/runtime.rs | 5 +++++ + 6 files changed, 19 insertions(+), 1 deletion(-) + +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index e5ffb30c4..c6b642345 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -8918,6 +8918,7 @@ impl Game<'_> { + create: Option<&horizon_client::title::WorldOptions>, + ) -> Result<()> { + world_loading::world_data_used(); ++ self.mods.world_id = Some(format!("world:{folder}")); + if self.defer_world_entry(PendingWorldEntry::Local { + folder: folder.to_owned(), + name: name.map(str::to_owned), +diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs +index 3eef96aa3..ebdd18a24 100644 +--- a/crates/horizon-client/src/game/mods.rs ++++ b/crates/horizon-client/src/game/mods.rs +@@ -38,6 +38,8 @@ use { + + #[derive(Default)] + pub(super) struct ModsState { ++ /// The open world, for `game.world-id` (AirDef fork): its folder, or the server's address. ++ pub(super) world_id: Option, + #[cfg(feature = "mods")] + pub(super) mods: Mods, + /// Capability `input`: a claimed item's use while the button is down. +@@ -482,6 +484,7 @@ impl Game<'_> { + known_recipes, + placing: self.mod_recipe_screen(), + creative: self.creative, ++ world: self.player.is_some().then(|| self.mods.world_id.clone()).flatten(), + } + } + /// The first block along the player's view within +diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs +index 22b7c13df..89df0616b 100644 +--- a/crates/horizon-client/src/game/remote.rs ++++ b/crates/horizon-client/src/game/remote.rs +@@ -342,6 +342,10 @@ impl Game<'_> { + cookies: Option, + ) -> Result<()> { + self.perf.world_opening(); ++ // A server's own world; joining the world this window hosts keeps its folder. ++ if local.is_none() { ++ self.mods.world_id = Some(format!("server:{address}")); ++ } + let root = self.options.resources.clone(); + let builtin = Builtin::find(&root) + .and_then(|p| Builtin::load(&p).ok()) +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index e30f3a30e..b6758d9fb 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -47,6 +47,10 @@ interface game { + get-player: func() -> option; + /// None on the title screen. + get-clock: func() -> option; ++ /// Which world is open (AirDef fork): `world:` for a saved ++ /// world, `server:
` on a server; none on the title screen. ++ /// Mods keep their state apart by it. ++ world-id: func() -> option; + /// A block along the player's view. + record hit { + /// The block hit. +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index 9d15851b4..cf2993186 100644 +--- a/crates/horizon-mods/src/api_level.rs ++++ b/crates/horizon-mods/src/api_level.rs +@@ -13,7 +13,7 @@ pub const LEVEL: u32 = 9; + /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new + /// function takes the next level and raises [`LEVEL`] (the `api_reference` + /// test fails until it is listed here). +-pub const FUNCTIONS: [(&str, u32); 55] = [ ++pub const FUNCTIONS: [(&str, u32); 56] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -70,6 +70,7 @@ pub const FUNCTIONS: [(&str, u32); 55] = [ + ("render.clear-post", 8), + ("render.set-params", 8), + ("render.set-views", 9), ++ ("game.world-id", 9), + ]; + + /// What the game's program carries so the launcher can read its level +diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs +index 1389407be..abed5b31f 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -168,6 +168,8 @@ pub struct Snapshot { + pub placing: bool, + /// The player is in creative mode (`recipes.give`). + pub creative: bool, ++ /// The open world (`game.world-id`, AirDef fork). ++ pub world: Option, + } + + /// Capability `recipes`'s tables: `items` in the registry's order, which +@@ -1475,6 +1477,9 @@ impl game::Host for State { + fn get_clock(&mut self) -> Option { + self.snapshot.clock + } ++ fn world_id(&mut self) -> Option { ++ self.snapshot.world.clone() ++ } + fn raycast(&mut self, distance: f32) -> Option { + let (at, hit) = self.snapshot.view_hit.as_ref()?; + // NaN compares false: no hit. +-- +2.54.0 (Apple Git-157) +