Oreshnik with MIRV, impact power tiers, far missile glow, SAM cameras and command post grid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 22:31:14 +03:00
parent 2ec7861173
commit 43346aecfe
57 changed files with 1662 additions and 126 deletions
+13 -3
View File
@@ -45,13 +45,18 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо
**Монитор комплекса:** клавиша **K** листает комплексы по очереди, после последнего монитор скрывается. Слева вверху показано:
- состояние комплекса (поиск, сопровождение, доворот, захват, пуск, огонь, перезарядка, огонь запрещён);
- его РЛС: цели, по которым он работает, красным, остальные серым, текущая цель жёлтым;
- тепловизионная камера с 3D-моделью цели и рамкой захвата, дальность, высота, скорость и пеленг.
- камера комплекса: настоящий вид мира из точки комплекса (второй рендер форка), с зумом на цель — тепловизор
при сопровождении, в поиске днём цвет, ночью ПНВ; рамка захвата, дальность, высота, скорость и пеленг.
Клавиши N и K можно переназначить в «Настройки → Моды».
**Командный пункт ПВО (сетка):** клавиша **J** (или `/airdef grid`, или кнопка в меню N) — на весь экран сетка из
всех комплексов (до 8): у каждого состояние, РЛС, живая камера и боекомплект. Повторное J закрывает.
Клавиши N, K и J можно переназначить в «Настройки → Моды».
Команды:
- `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal` и `mix` (комбинированный удар: «Герберы», шахеды, Герани-3, Х-101, «Калибры» и «Искандер»);
- `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар: «Герберы», шахеды, Герани-3, Х-101, «Калибры» и «Искандер»);
- `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
- `/airdef deploy <patriot|gepard|pantsir|s400|s500>` — поставить комплекс в 8 блоках перед собой;
- `/airdef hold on|off` — запретить или разрешить ПВО огонь, удобно для съёмки ударов;
- `/airdef damage on|off` — разрушать ли ландшафт взрывами (через TNT, нужны читы);
- `/airdef shader on|off` — шейдерные эффекты;
@@ -79,6 +84,11 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо
| Циркон | гиперзвуковая, 400 км/ч, её трудно сбить |
| Искандер-М | баллистическая: стартует за 1 км, дуга с апогеем ~250 м, на конечном участке доворачивает на цель |
| Кинжал | аэробаллистическая, 540 км/ч, стартует за 1,5 км; реально сбивает только С-500 |
| Орешник | баллистическая средней дальности: стартует за 2,6 км, над целью разделяется на 6 блоков, каждый — на 6 светящихся суббоеприпасов («огненный дождь», ~5 с); С-500 успевает сбить лишь несколько |
Сила взрыва зависит от боевой части: ложные цели — хлопок, дроны — средний взрыв, Шахед — сильный, Х-101/Х-22/Калибр —
тяжёлый (двойной удар, столб земли, тряска до 160 м), Искандер/Кинжал — самый мощный (огненный шар 13 м, тряска до 240 м);
у каждого уровня свой звук.
| ПВО | Вооружение | Дальность |
|---|---|---|
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/reentry"
}
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "airdef:item/cam_reentry"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "airdef:item/launch_oreshnik"
}
}
+4 -1
View File
@@ -39,6 +39,7 @@
"block.airdef.iskander": "Искандер-М",
"block.airdef.kinzhal": "Кинжал",
"block.airdef.tracer": "Трассер",
"block.airdef.reentry": "Боевой блок",
"item.airdef.designator": "Целеуказатель",
"item.airdef.launch_shahed": "Пуск: Шахед-136",
"item.airdef.launch_geran3": "Пуск: Герань-3",
@@ -54,6 +55,7 @@
"item.airdef.launch_zircon": "Пуск: Циркон",
"item.airdef.launch_iskander": "Пуск: Искандер-М",
"item.airdef.launch_kinzhal": "Пуск: Кинжал",
"item.airdef.launch_oreshnik": "Пуск: Орешник",
"item.airdef.remover": "Свернуть ПВО",
"item.airdef.cam_shahed": "Шахед-136",
"item.airdef.cam_geran3": "Герань-3",
@@ -64,5 +66,6 @@
"item.airdef.cam_kh22": "Х-22",
"item.airdef.cam_zircon": "Циркон",
"item.airdef.cam_iskander": "Искандер-М",
"item.airdef.cam_kinzhal": "Кинжал"
"item.airdef.cam_kinzhal": "Кинжал",
"item.airdef.cam_reentry": "Боевой блок"
}
+4 -1
View File
@@ -39,6 +39,7 @@
"block.airdef.iskander": "Искандер-М",
"block.airdef.kinzhal": "Кинжал",
"block.airdef.tracer": "Трассер",
"block.airdef.reentry": "Боевой блок",
"item.airdef.designator": "Целеуказатель",
"item.airdef.launch_shahed": "Пуск: Шахед-136",
"item.airdef.launch_geran3": "Пуск: Герань-3",
@@ -54,6 +55,7 @@
"item.airdef.launch_zircon": "Пуск: Циркон",
"item.airdef.launch_iskander": "Пуск: Искандер-М",
"item.airdef.launch_kinzhal": "Пуск: Кинжал",
"item.airdef.launch_oreshnik": "Пуск: Орешник",
"item.airdef.remover": "Свернуть ПВО",
"item.airdef.cam_shahed": "Шахед-136",
"item.airdef.cam_geran3": "Герань-3",
@@ -64,5 +66,6 @@
"item.airdef.cam_kh22": "Х-22",
"item.airdef.cam_zircon": "Циркон",
"item.airdef.cam_iskander": "Искандер-М",
"item.airdef.cam_kinzhal": "Кинжал"
"item.airdef.cam_kinzhal": "Кинжал",
"item.airdef.cam_reentry": "Боевой блок"
}
@@ -0,0 +1,215 @@
{
"ambientocclusion": false,
"textures": {
"white": "airdef:block/white",
"tracer_hot": "airdef:block/tracer_hot",
"flame": "airdef:block/flame",
"particle": "airdef:block/white"
},
"elements": [
{
"from": [
6.5,
6.5,
-16.0
],
"to": [
9.5,
9.5,
-8.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#white"
},
"south": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#white"
},
"east": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#white"
},
"west": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#white"
},
"up": {
"uv": [
0,
0,
3.0,
8.0
],
"texture": "#white"
},
"down": {
"uv": [
0,
0,
3.0,
8.0
],
"texture": "#white"
}
}
},
{
"from": [
7.0,
7.0,
-8.0
],
"to": [
9.0,
9.0,
12.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#tracer_hot"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#tracer_hot"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#tracer_hot"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#tracer_hot"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#tracer_hot"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#tracer_hot"
}
}
},
{
"from": [
7.5,
7.5,
12.0
],
"to": [
8.5,
8.5,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
1.0,
1.0
],
"texture": "#flame"
},
"south": {
"uv": [
0,
0,
1.0,
1.0
],
"texture": "#flame"
},
"east": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#flame"
},
"west": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#flame"
},
"up": {
"uv": [
0,
0,
1.0,
16
],
"texture": "#flame"
},
"down": {
"uv": [
0,
0,
1.0,
16
],
"texture": "#flame"
}
}
}
]
}
@@ -0,0 +1,22 @@
{
"parent": "airdef:block/reentry",
"display": {
"gui": {
"rotation": [
20,
145,
0
],
"translation": [
0,
0,
0
],
"scale": [
0.36,
0.36,
0.36
]
}
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "airdef:item/launch_oreshnik"
}
}
+32
View File
@@ -215,6 +215,38 @@
}
]
},
"blast.heavy": {
"sounds": [
{
"name": "airdef:blast_heavy",
"attenuation_distance": 1300
}
]
},
"blast.heavy_far": {
"sounds": [
{
"name": "airdef:blast_heavy_far",
"attenuation_distance": 2080
}
]
},
"blast.huge": {
"sounds": [
{
"name": "airdef:blast_huge",
"attenuation_distance": 1700
}
]
},
"blast.huge_far": {
"sounds": [
{
"name": "airdef:blast_huge_far",
"attenuation_distance": 2720
}
]
},
"blast.medium": {
"sounds": [
{
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 407 B

+59 -13
View File
@@ -18,7 +18,7 @@ mod radar;
mod sim;
use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim};
const ITEMS: [&str; 16] = [
const ITEMS: [&str; 17] = [
"airdef:designator",
"airdef:launch_shahed",
"airdef:launch_geran3",
@@ -29,6 +29,7 @@ const ITEMS: [&str; 16] = [
"airdef:launch_zircon",
"airdef:launch_iskander",
"airdef:launch_kinzhal",
"airdef:launch_oreshnik",
"airdef:deploy_patriot",
"airdef:deploy_gepard",
"airdef:deploy_pantsir",
@@ -45,6 +46,8 @@ struct AirDef {
/// The system the monitor shows (K cycles), none: hidden.
monitor: Option<usize>,
monitor_shown: bool,
/// Every system's monitor in a grid (J).
grid: bool,
/// The menu's page while it is open.
menu: Option<Page>,
/// Where the player is, for the sounds' delay and far variants.
@@ -75,6 +78,7 @@ const BTN_HOLD: u32 = 10;
const BTN_SHADER: u32 = 11;
const BTN_DAMAGE: u32 = 12;
const BTN_MONITOR: u32 = 13;
const BTN_GRID: u32 = 14;
const BTN_CLASS: u32 = 100;
const BTN_ALL: u32 = 120;
const BTN_DEFAULT: u32 = 121;
@@ -198,6 +202,7 @@ impl AirDef {
"airdef:flash" => (1.0, 0.85, 0.5, 2.0),
"airdef:target" => (1.0, 0.1, 0.1, 0.5),
"airdef:tracer" => (1.0, 0.12, 0.05, 0.9),
"airdef:reentry" => (1.0, 0.6, 0.3, 1.4),
_ => continue,
};
p.extend([m.at.x as f32, m.at.y as f32, m.at.z as f32, m.scale * 1.6]);
@@ -290,7 +295,8 @@ impl AirDef {
fn draw_hud(&mut self, player: DVec3, yaw: f32) {
let air: Vec<&sim::Drone> = self.sim.drones.iter().filter(|d| d.alive()).collect();
if air.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty() {
// The command post's grid covers the screen: the other panels wait under it.
if self.grid || (air.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) {
if self.hud_shown {
hud::clear();
render::clear_corner();
@@ -401,6 +407,7 @@ impl AirDef {
None => "скрыт".into(),
};
widgets.push(Widget::Button(ButtonWidget { id: BTN_MONITOR, label: format!("Монитор: {monitor}") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_GRID, label: "Командный пункт: все комплексы (J)".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {}", onoff(self.sim.shader)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_DAMAGE, label: format!("Разрушения: {}", onoff(self.sim.damage)) }));
}
@@ -442,7 +449,7 @@ impl AirDef {
}
}
const HELP: &str = "/airdef menu | monitor | give | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off | hold on|off | roles [комплекс классы] | stats";
const HELP: &str = "/airdef menu | monitor | grid | 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> {
@@ -454,12 +461,14 @@ impl Mod for AirDef {
hud_shown: false,
monitor: None,
monitor_shown: false,
grid: false,
menu: None,
listener: DVec3::ZERO,
pending: Vec::new(),
};
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
let _ = input::register_key("grid", "Командный пункт: все комплексы", "key.keyboard.j");
me.load();
input::claim_items(&ITEMS.map(String::from));
render::set_post(include_str!("../shaders/effects.wgsl"));
@@ -474,6 +483,7 @@ impl Mod for AirDef {
Event::UseStart(u) => self.use_item(&u.item.id),
Event::KeyDown(k) if k == "menu" => self.open_menu(Page::Main),
Event::KeyDown(k) if k == "monitor" => self.cycle_monitor(),
Event::KeyDown(k) if k == "grid" => self.grid = !self.grid,
_ => {}
}
}
@@ -488,15 +498,25 @@ impl Mod for AirDef {
}
self.emit(out);
self.draw_hud(DVec3::new(p.x, p.y, p.z), p.yaw);
match self.monitor {
Some(i) if i < self.sim.sams.len() => {
render::set_hud(&monitor::draw(&self.sim, i));
self.monitor_shown = true;
}
_ => {
if self.monitor_shown {
render::set_hud(&[]);
self.monitor_shown = false;
// The monitors and the cameras they show.
if self.grid {
render::set_hud(&monitor::grid(&self.sim, render::gui_size()));
let views: Vec<_> = self.sim.sams.iter().take(8).map(|s| monitor::camera(&self.sim, s)).collect();
render::set_views(&views);
self.monitor_shown = true;
} else {
match self.monitor {
Some(i) if i < self.sim.sams.len() => {
render::set_hud(&monitor::draw(&self.sim, i));
render::set_views(&[monitor::camera(&self.sim, &self.sim.sams[i])]);
self.monitor_shown = true;
}
_ => {
if self.monitor_shown {
render::set_hud(&[]);
render::set_views(&[]);
self.monitor_shown = false;
}
}
}
}
@@ -516,6 +536,12 @@ impl Mod for AirDef {
}
(Page::Main, BTN_HOLD) => self.sim.hold = !self.sim.hold,
(Page::Main, BTN_MONITOR) => self.cycle_monitor(),
(Page::Main, BTN_GRID) => {
self.grid = true;
self.menu = None;
ui::close_screen();
return;
}
(Page::Main, BTN_SHADER) => {
self.sim.shader = !self.sim.shader;
if self.sim.shader {
@@ -560,10 +586,30 @@ impl Mod for AirDef {
self.cycle_monitor();
None
}
["grid"] => {
self.grid = !self.grid;
None
}
["give"] => {
self.give();
None
}
["deploy", kind] => {
// A system where the player stands, a few blocks ahead.
let kind = SamKind::from_key(kind).ok_or("Комплексы: patriot, gepard, pantsir, s400, s500")?;
let p = player().ok_or("Нет игрока")?;
let d = view(&p);
let flat = DVec3::new(d.x, 0.0, d.z).normalize_or(DVec3::NEG_Z);
let at = DVec3::new(p.x, p.y, p.z) + flat * 8.0;
let sid = self.sim.id();
let mut sam = Sam::new(sid, kind, at, (-flat.x).atan2(-flat.z));
if let Some(r) = self.sim.roles.get(&kind) {
sam.roles = *r;
}
self.sim.sams.push(sam);
self.dirty = true;
Some(format!("{} развёрнут", kind.name()))
}
["wave", kind, rest @ ..] => {
let count = rest.first().and_then(|v| v.parse().ok()).unwrap_or(5u32).clamp(1, 80);
let dist = rest.get(1).and_then(|v| v.parse().ok()).unwrap_or(260.0f64).clamp(60.0, 800.0);
@@ -571,7 +617,7 @@ impl Mod for AirDef {
let target = self
.sim
.nearest_target(DVec3::new(p.x, p.y, p.z))
.ok_or("Сначала отметьте цель Целеуказателем")?;
.unwrap_or(DVec3::new(p.x, p.y, p.z));
if *kind == "mix" {
// A combined strike: decoys first, then Shaheds, jets and cruise missiles.
let n = count.max(4);
+144 -88
View File
@@ -1,14 +1,11 @@
//! The air-defence monitor (top left): one system at a time (K cycles them):
//! its state, its radar's picture round it and a thermal camera locked on
//! its target.
//! The air-defence monitor: a system's state, its radar's picture round it
//! and its camera (a real view of the world from the system, the game
//! renders it) locked on its target. K shows one system at a time, J all of
//! them in a grid.
use crate::panel::distance;
use crate::sim::{Sim, SamState, heading_of, roles_text};
use horizon_mod_sdk::render::{FillShape, ItemShape, Point, QuadShape, Rect, Shape, TextShape};
const W: f32 = 212.0;
const H: f32 = 132.0;
const X: f32 = 4.0;
const Y: f32 = 4.0;
use crate::sim::{Sam, SamState, Sim, heading_of, roles_text};
use glam::DVec3;
use horizon_mod_sdk::render::{self, CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape};
fn fill(x: f32, y: f32, w: f32, h: f32, color: u32) -> Shape {
Shape::Fill(FillShape { rect: Rect { x, y, w, h }, color })
@@ -23,6 +20,18 @@ fn quad(a: (f32, f32), b: (f32, f32), c: (f32, f32), d: (f32, f32), color: u32)
Shape::Quad(QuadShape { corners: (p(a), p(b), p(c), p(d)), color })
}
/// `t` cut to `w` GUI pixels, with an ellipsis.
fn fit(t: String, w: f32) -> String {
if render::text_width(&t) <= w {
return t;
}
let mut cut: String = t;
while !cut.is_empty() && render::text_width(&format!("{cut}…")) > w {
cut.pop();
}
format!("{cut}…")
}
fn argb(rgb: u32, a: u32) -> u32 {
(a << 24) | (rgb & 0xFFFFFF)
}
@@ -37,25 +46,52 @@ fn brackets(s: &mut Vec<Shape>, x: f32, y: f32, w: f32, h: f32, len: f32, color:
}
}
pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
let mut s = Vec::new();
let Some(sam) = sim.sams.get(index) else { return s };
/// The camera's picture size, pixels.
const PICTURE: (u32, u32) = (320, 200);
/// Where system `sam`'s camera is and looks: at its target, zoomed so the
/// target fills a good part of the picture; else ahead and a little up.
pub fn camera(sim: &Sim, sam: &Sam) -> CameraView {
let eye = sam.pos + DVec3::Y * 3.5;
let (dir, fov) = match sim.watched(sam) {
Some(d) => {
let rel = d.pos - eye;
let dist = rel.length().max(1.0);
// About 14 blocks of view round a missile far off, wider when it is near.
let fov = (2.0 * (7.0 / dist).atan()).to_degrees().clamp(1.5, 50.0);
(rel / dist, fov as f32)
}
None => {
let h = sam.heading + (sim.tick as f64 * 0.004).sin() * 0.6;
(DVec3::new(-h.sin(), 0.25, -h.cos()).normalize(), 55.0)
}
};
// Minecraft's yaw: 0 looks to +z, 90 to -x; pitch 90 looks down.
let yaw = (-dir.x).atan2(dir.z).to_degrees() as f32;
let pitch = (-dir.y).asin().to_degrees() as f32;
CameraView { id: sam.id, x: eye.x, y: eye.y, z: eye.z, yaw, pitch, fov, width: PICTURE.0, height: PICTURE.1 }
}
/// One system's monitor in the box (x, y, w, h).
fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Shape>) {
let Some(sam) = sim.sams.get(index) else { return };
let spec = sam.kind.spec();
let tick = sim.tick;
// Frame.
s.push(fill(X, Y, W, H, 0xC0080C0A));
s.push(fill(X, Y, W, 12.0, 0xE0142018));
s.push(fill(x, y, w, h, 0xC0080C0A));
s.push(fill(x, y, w, 12.0, 0xE0142018));
let state = sam.state;
let blink = matches!(state, SamState::Lock | SamState::Launch | SamState::Fire) && (tick / 4) % 2 == 0;
s.push(text(format!("{} №{}", spec.name, index + 1), X + 3.0, Y + 2.0, 0xFF9FE0FF));
let lock = matches!(state, SamState::Lock | SamState::Launch | SamState::Fire);
let blink = lock && (tick / 4) % 2 == 0;
let label = state.name();
let lx = X + W - 3.0 - label.chars().count() as f32 * 6.0;
s.push(text(label, lx, Y + 2.0, argb(state.color(), if blink { 0x80 } else { 0xFF })));
let lw = render::text_width(label);
s.push(text(fit(format!("№{} {}", index + 1, spec.name), w - lw - 12.0), x + 3.0, y + 2.0, 0xFF9FE0FF));
s.push(text(label, x + w - 3.0 - lw, y + 2.0, argb(state.color(), if blink { 0x80 } else { 0xFF })));
// The system's radar: round it, its reach at the edge, north up.
let (rx, ry, rr) = (X + 48.0, Y + 64.0, 44.0);
// The radar, at the left: round the system, north up.
let rr = ((h - 28.0) / 2.0).min(w * 0.2).max(10.0);
let (rx, ry) = (x + 4.0 + rr, y + 14.0 + (h - 26.0) / 2.0);
s.push(fill(rx - rr, ry - rr, rr * 2.0, rr * 2.0, 0x60103018));
let seg = 32;
let seg = if rr > 30.0 { 32 } else { 20 };
for k in [0.5f32, 1.0] {
for i in 0..seg {
let a0 = i as f32 / seg as f32 * std::f32::consts::TAU;
@@ -66,14 +102,14 @@ pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
}
}
let reach = sam.kind.range();
let sweep = (tick as f32 * 0.12) % std::f32::consts::TAU;
for k in 0..8 {
let a1 = sweep - k as f32 * 0.07;
let a0 = a1 - 0.07;
let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU;
for k in 0..6 {
let a1 = sweep - k as f32 * 0.09;
let a0 = a1 - 0.09;
let p = |a: f32| (rx + a.sin() * rr, ry - a.cos() * rr);
s.push(quad((rx, ry), p(a0), p(a1), p(a1), argb(0x40FF70, (0x70 - k * 8) as u32)));
s.push(quad((rx, ry), p(a0), p(a1), p(a1), argb(0x40FF70, (0x70 - k * 10) as u32)));
}
let target = sam.gun_target.or(sam.target).and_then(|t| sim.drones.iter().find(|d| d.id == t));
let target = sim.watched(sam);
let mut seen = 0;
for d in sim.drones.iter().filter(|d| d.alive()) {
let rel = d.pos - sam.pos;
@@ -83,7 +119,7 @@ pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
}
seen += 1;
let k = (rr as f64 / (reach * 1.25)) as f32;
let (x, y) = (rx + rel.x as f32 * k, ry + rel.z as f32 * k);
let (bx, by) = (rx + rel.x as f32 * k, ry + rel.z as f32 * k);
let allowed = sam.roles & d.kind.spec().class.bit() != 0;
let color = if Some(d.id) == target.map(|t| t.id) {
0xFFFFE040
@@ -92,84 +128,104 @@ pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
} else {
0xFF808080
};
s.push(fill(x - 1.5, y - 1.5, 3.0, 3.0, color));
s.push(fill(bx - 1.5, by - 1.5, 3.0, 3.0, color));
}
s.push(fill(rx - 1.5, ry - 1.5, 3.0, 3.0, 0xFF40A0FF));
s.push(text(format!("РЛС {} · {}", distance(reach), seen), X + 4.0, Y + H - 10.0, 0xFF80FF90));
s.push(text(fit(format!("РЛС {} · {}", distance(reach), seen), rr * 2.0 + 4.0), x + 4.0, y + h - 10.0, 0xFF80FF90));
// The camera: a thermal picture of the target, the lock box.
let (cx, cy, cw, ch) = (X + 98.0, Y + 16.0, W - 102.0, 92.0);
s.push(fill(cx, cy, cw, ch, 0xF0101410));
// Scan lines.
for i in 0..(ch as i32 / 4) {
let y = cy + i as f32 * 4.0 + ((tick % 4) as f32);
if y < cy + ch {
s.push(fill(cx, y, cw, 1.0, 0x18A0FFA0));
}
}
// The camera, at the right: the world as the system sees it, thermal.
let cx = rx + rr + 6.0;
let (cy, cw, ch) = (y + 15.0, x + w - 4.0 - cx, h - 28.0);
s.push(fill(cx - 1.0, cy - 1.0, cw + 2.0, ch + 2.0, 0xFF203020));
// Searching: colour by day, night vision at night; tracking: thermal.
let night = horizon_mod_sdk::game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000)));
let mode = match (target.is_some(), night) {
(true, _) => 1,
(false, true) => 2,
(false, false) => 0,
};
s.push(Shape::View(ViewShape { view: sam.id, rect: Rect { x: cx, y: cy, w: cw, h: ch }, mode }));
// Crosshair.
s.push(fill(cx + cw / 2.0 - 0.5, cy + 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
s.push(fill(cx + cw / 2.0 - 0.5, cy + ch * 0.82 - 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
s.push(fill(cx + 3.0, cy + ch / 2.0 - 0.5, cw * 0.18, 1.0, 0x90FFFFFF));
s.push(fill(cx + cw * 0.82 - 3.0, cy + ch / 2.0 - 0.5, cw * 0.18, 1.0, 0x90FFFFFF));
match target {
Some(t) => {
let size = 3.0;
let ix = cx + cw / 2.0 - 8.0 * size;
let iy = cy + ch / 2.0 - 8.0 * size;
// Track wobble: the picture jitters while the turret is still slewing.
let jitter = if state == SamState::Slew { ((tick * 7) % 5) as f32 - 2.0 } else { 0.0 };
s.push(Shape::Item(ItemShape {
item: format!("airdef:cam_{}", t.kind.spec().key),
at: Point { x: ix + jitter, y: iy - jitter * 0.5 },
scale: size,
}));
// A warm tint over the image, as a thermal camera's.
s.push(fill(cx, cy, cw, ch, 0x2840FF40));
let lock = matches!(state, SamState::Lock | SamState::Launch | SamState::Fire);
let color = if lock { if blink { 0xFFFF3030 } else { 0xFFFF8080 } } else { 0xFFFFE060 };
let (bw, bh) = (54.0, 40.0);
brackets(&mut s, cx + cw / 2.0 - bw / 2.0, cy + ch / 2.0 - bh / 2.0, bw, bh, 7.0, color);
s.push(fill(cx + cw / 2.0 - 0.5, cy + 4.0, 1.0, 10.0, 0x80FFFFFF));
s.push(fill(cx + cw / 2.0 - 0.5, cy + ch - 14.0, 1.0, 10.0, 0x80FFFFFF));
let (bw, bh) = (cw * 0.3, ch * 0.34);
brackets(s, cx + cw / 2.0 - bw / 2.0, cy + ch / 2.0 - bh / 2.0, bw, bh, 6.0, color);
if lock {
s.push(text("ЗАХВАТ", cx + cw / 2.0 - 18.0, cy + 3.0, color));
}
if !t.alive() {
s.push(text("ПОРАЖЕНА", cx + cw / 2.0 - 24.0, cy + ch / 2.0 + bh / 2.0 + 2.0, 0xFF60FF60));
}
let rel = t.pos - sam.pos;
let bearing = (heading_of(rel).to_degrees() + 180.0).rem_euclid(360.0);
s.push(text(t.kind.spec().name, cx + 3.0, cy + ch - 20.0, 0xFFFFFFFF));
s.push(text(
format!(
"{} в{:.0} {:.0}км/ч П{:03.0}",
distance(rel.length()),
t.pos.y - sam.pos.y,
t.vel.length() * 72.0,
bearing
),
cx + 3.0,
cy + ch - 10.0,
0xFFC0FFC0,
));
}
None => {
let noise = (tick * 2654435761) % 997;
for i in 0..14 {
let x = cx + ((noise as f32 * (i + 3) as f32 * 13.7) % cw);
let y = cy + ((noise as f32 * (i + 7) as f32 * 7.3) % ch);
s.push(fill(x, y, 2.0, 1.0, 0x50FFFFFF));
if ch > 50.0 {
s.push(text(fit(t.kind.spec().name.into(), cw - 6.0), cx + 3.0, cy + ch - 20.0, 0xFFFFFFFF));
}
s.push(text("НЕТ ЦЕЛИ", cx + cw / 2.0 - 24.0, cy + ch / 2.0 - 4.0, 0xFF80FF90));
let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), t.pos.y - sam.pos.y, t.vel.length() * 72.0);
if cw > 220.0 {
info += &format!(" П{bearing:03.0}");
}
s.push(text(fit(info, cw - 6.0), cx + 3.0, cy + ch - 10.0, 0xFFC0FFC0));
}
None => s.push(text("ПОИСК", cx + 3.0, cy + ch - 10.0, 0xFF80FF90)),
}
// What it carries and does.
let mut line = String::new();
if spec_missiles(sam) {
line += &format!("ракеты {} ", sam.missiles);
if !matches!(sam.kind, crate::sim::SamKind::Gepard) {
line += &format!("Р{} ", sam.missiles);
}
if sam.bursts > 0 || sam.kind.spec().key == "gepard" {
line += &format!("снар. {} ", sam.bursts);
if sam.bursts > 0 || spec.key == "gepard" {
line += &format!("С{} ", sam.bursts);
}
line += &format!("сбил {} · [{}]", sam.kills, roles_text(sam.roles));
s.push(text(line, cx, Y + H - 10.0, 0xFF9FE0FF));
line += &format!("сб.{} [{}]", sam.kills, roles_text(sam.roles));
s.push(text(fit(line, cw), cx, y + h - 10.0, 0xFF9FE0FF));
}
/// One system, top left.
pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
let mut s = Vec::new();
cell(sim, index, 4.0, 4.0, 300.0, 150.0, &mut s);
s
}
fn spec_missiles(sam: &crate::sim::Sam) -> bool {
!matches!(sam.kind, crate::sim::SamKind::Gepard)
/// Every system in a grid over the screen.
pub fn grid(sim: &Sim, (gw, gh): (f32, f32)) -> Vec<Shape> {
let mut s = Vec::new();
let n = sim.sams.len().min(8);
s.push(fill(0.0, 0.0, gw, gh, 0xE0050806));
s.push(text(
fit(
format!("КОМАНДНЫЙ ПУНКТ ПВО · комплексов {n} · целей {} · сбито {} · J закрыть", sim.drones.iter().filter(|d| d.alive()).count(), sim.kills),
gw - 16.0,
),
8.0,
6.0,
0xFF9FE0FF,
));
if n == 0 {
s.push(text("Нет комплексов: поставьте их пультами развёртывания", 8.0, 24.0, 0xFFFFC060));
return s;
}
let cols = match n {
1 => 1,
2..=4 => 2,
_ => 3,
};
let rows = n.div_ceil(cols);
let (top, gap) = (18.0, 4.0);
let cw = (gw - gap * (cols as f32 + 1.0)) / cols as f32;
let ch = ((gh - top - gap * rows as f32) / rows as f32).min(cw * 0.62);
for i in 0..n {
let (c, r) = (i % cols, i / cols);
let x = gap + c as f32 * (cw + gap);
let y = top + r as f32 * (ch + gap);
cell(sim, i, x, y, cw, ch, &mut s);
}
s
}
+153 -15
View File
@@ -284,6 +284,11 @@ pub enum DroneKind {
Zircon,
Iskander,
Kinzhal,
/// An IRBM: its bus releases six blocks high over the target.
Oreshnik,
/// One of the Oreshnik's blocks: it splits into six glowing submunitions.
OreshnikBlock,
OreshnikSub,
}
/// What a drone carries on its tail.
@@ -325,7 +330,7 @@ pub struct DroneSpec {
}
impl DroneKind {
pub const ALL: [DroneKind; 10] = [
pub const ALL: [DroneKind; 13] = [
DroneKind::Shahed,
DroneKind::Geran3,
DroneKind::Lancet,
@@ -336,6 +341,9 @@ impl DroneKind {
DroneKind::Zircon,
DroneKind::Iskander,
DroneKind::Kinzhal,
DroneKind::Oreshnik,
DroneKind::OreshnikBlock,
DroneKind::OreshnikSub,
];
pub fn spec(self) -> &'static DroneSpec {
match self {
@@ -519,6 +527,60 @@ impl DroneKind {
class: Class::Ballistic,
ballistic: Some(1500.0),
},
DroneKind::Oreshnik => &DroneSpec {
name: "Орешник",
key: "oreshnik",
model: "airdef:iskander",
speed: 9.0,
turn: 0.0,
cruise: 0.0,
clearance: 0.0,
dive: 0.0,
warhead: 0,
scale: 2.6,
engine: Engine::Rocket(1.5, 2.4),
decoy: false,
stealth: 3.0,
sound: ("airdef:drone.rocket", 0.5, 46),
class: Class::Ballistic,
ballistic: Some(2600.0),
},
DroneKind::OreshnikBlock => &DroneSpec {
name: "Орешник: блок",
key: "oreshnik_block",
model: "airdef:reentry",
speed: 9.0,
turn: 0.0,
cruise: 0.0,
clearance: 0.0,
dive: 0.0,
warhead: 4,
scale: 1.6,
engine: Engine::Rocket(1.0, 1.0),
decoy: false,
stealth: 3.5,
sound: ("airdef:drone.ballistic", 0.8, 9999),
class: Class::Ballistic,
ballistic: None,
},
DroneKind::OreshnikSub => &DroneSpec {
name: "Орешник: суббоеприпас",
key: "oreshnik_sub",
model: "airdef:reentry",
speed: 9.0,
turn: 0.05,
cruise: 0.0,
clearance: 0.0,
dive: 0.0,
warhead: 3,
scale: 1.1,
engine: Engine::Rocket(1.0, 1.0),
decoy: false,
stealth: 4.0,
sound: ("airdef:drone.ballistic", 1.0, 9999),
class: Class::Ballistic,
ballistic: None,
},
}
}
pub fn from_key(key: &str) -> Option<Self> {
@@ -526,6 +588,9 @@ impl DroneKind {
}
}
/// Height over the target where the Oreshnik's blocks release their submunitions.
const BLOCK_SPLIT: f64 = 300.0;
#[derive(PartialEq)]
enum Flight {
Cruise,
@@ -1013,6 +1078,16 @@ impl Sim {
self.next * PARTS
}
/// What a system's camera follows: its guns' or missiles' target, else what its missiles in
/// flight are after.
pub fn watched(&self, sam: &Sam) -> Option<&Drone> {
let id = sam
.gun_target
.or(sam.target)
.or_else(|| self.missiles.iter().find(|m| m.sam == sam.id).map(|m| m.target))?;
self.drones.iter().find(|d| d.id == id)
}
pub fn nearest_target(&self, to: DVec3) -> Option<DVec3> {
self.targets.iter().copied().min_by(|a, b| a.distance_squared(to).total_cmp(&b.distance_squared(to)))
}
@@ -1096,6 +1171,8 @@ impl Sim {
let now = self.tick;
let mut boom: Vec<(DVec3, u32, bool)> = Vec::new();
let mut removed: Vec<u32> = Vec::new();
// Oreshnik pieces released this tick: kind, at, velocity, target.
let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3)> = Vec::new();
for d in &mut self.drones {
d.age += 1;
let spec = d.kind.spec();
@@ -1120,7 +1197,13 @@ impl Sim {
if d.flight == Flight::Ballistic {
// The motor burns for 3 s; then the arc, and the warhead steers in at the end.
let burning = d.age < 60;
d.vel.y -= GRAVITY;
match d.kind {
// A block glides to its point high over the target; its submunitions fall from there
// nearly straight down, braked by the air: a rain of fire for seconds.
DroneKind::OreshnikBlock => d.vel = (d.target + DVec3::Y * BLOCK_SPLIT - d.pos).normalize_or(DVec3::NEG_Y) * 6.5,
DroneKind::OreshnikSub => d.vel = (d.target - d.pos).normalize_or(DVec3::NEG_Y) * 3.2,
_ => d.vel.y -= GRAVITY,
}
if d.vel.y < 0.0 && d.pos.distance(d.target) < 260.0 {
let sp = d.vel.length();
d.vel = turn(d.vel, d.target - d.pos, spec.turn) * sp;
@@ -1129,6 +1212,7 @@ impl Sim {
let speed = d.vel.length();
let hit = d.pos.distance(d.target) < speed.max(2.0)
|| below.is_some_and(|g| d.pos.y <= g + 0.2)
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0)
|| d.age > 4000;
if hit {
boom.push((d.pos, spec.warhead, false));
@@ -1136,8 +1220,39 @@ impl Sim {
self.hits += 1;
continue;
}
// The bus releases its six blocks over the target, each block its six submunitions lower down.
let split = match d.kind {
DroneKind::Oreshnik => (d.vel.y < 0.0 && horizontal(d.target - d.pos).length() < 900.0)
.then_some((DroneKind::OreshnikBlock, 45.0)),
DroneKind::OreshnikBlock => (d.pos.distance(d.target + DVec3::Y * BLOCK_SPLIT) < 8.0)
.then_some((DroneKind::OreshnikSub, 9.0)),
_ => None,
};
if let Some((piece, spread)) = split {
let turn0 = self.rng.f() * TAU;
for i in 0..6 {
let a = turn0 + i as f64 * TAU / 6.0;
let r = spread * (0.6 + 0.4 * self.rng.f());
let aim = d.target + DVec3::new(a.sin() * r, 0.0, a.cos() * r);
// Straight down at the aim point, at the bus's speed.
let v = (aim - d.pos).normalize_or(DVec3::NEG_Y) * 3.0;
released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim));
}
out.sound("airdef:boom.sonic", d.pos, 1.0, 0.7);
// Its pieces count as launched instead of it.
self.launched += 5;
out.particles("minecraft:firework", d.pos, 1.5, DVec3::ZERO, 0.3, 40);
if d.kind == DroneKind::OreshnikBlock && self.rng.chance(0.5) {
out.sound("airdef:drone.ballistic", d.pos, 1.0, 1.1);
}
removed.push(d.id);
continue;
}
let dir = d.vel.normalize_or_zero();
if burning {
if d.kind == DroneKind::OreshnikSub || d.kind == DroneKind::OreshnikBlock {
out.particles("minecraft:flame", d.pos - dir * 1.5, 0.1, DVec3::ZERO, 0.0, 1);
out.particles("minecraft:white_smoke", d.pos - dir * 3.0, 0.15, DVec3::ZERO, 0.0, 1);
} else if burning {
out.particles("minecraft:flame", d.pos - dir * 2.5 * spec.scale, 0.15, -dir * 0.2, 0.02, 3);
out.particles("minecraft:cloud", d.pos - dir * 3.5 * spec.scale, 0.4, DVec3::ZERO, 0.01, 3);
} else if d.vel.y < -2.0 {
@@ -1213,6 +1328,10 @@ impl Sim {
self.explode(pos, warhead, small, ground, player, out);
}
self.drones.retain(|d| !removed.contains(&d.id));
for (kind, pos, vel, target) in released {
let id = self.id();
self.drones.push(Drone { id, kind, pos, vel, target, flight: Flight::Ballistic, roll: 0.0, spin: 1.0, age: 60, engaged: 0 });
}
for d in &self.drones {
let spec = d.kind.spec();
let s = spec.scale;
@@ -1220,7 +1339,9 @@ impl Sim {
if d.flight == Flight::Falling {
rot = rot * DQuat::from_rotation_z(d.spin) * DQuat::from_rotation_x(-0.6);
}
out.model(d.id, spec.model, d.pos, rot, s, false);
if !matches!(d.kind, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) || !d.alive() {
out.model(d.id, spec.model, d.pos, rot, s, false);
}
if !d.alive() {
continue;
}
@@ -1236,10 +1357,18 @@ impl Sim {
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();
if d.flight != Flight::Ballistic || d.age < 60 {
let flicker = 0.8 + 0.2 * self.rng.f();
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);
out.model(d.id + 1, "airdef:flame", at, rot, size * flicker, true);
} else if !matches!(d.kind, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) {
out.model(d.id + 1, "airdef:reentry", d.pos, rot, s * 0.6 * far.sqrt() * flicker, true);
} else {
out.model(d.id + 1, "airdef:reentry", d.pos, rot, s * far.sqrt().min(4.0) * flicker, true);
}
}
}
@@ -1250,14 +1379,17 @@ impl Sim {
/// `damage`) primed TNT for the land.
fn explode(&mut self, pos: DVec3, tnt: u32, small: bool, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
let big = tnt >= 3;
let size = match tnt {
0 => 2.5,
1 | 2 => 4.5,
3..=4 => 7.0,
_ => 9.0,
// Power: 0 air burst, 1 small, 2 drone, 3 cruise missile, 4 ballistic.
let power = match tnt {
0 => 0,
1 | 2 => 1,
3..=5 => 2,
6 | 7 => 3,
_ => 4,
};
self.fireball(pos, size, if big { 18 } else { 11 });
out.particles("minecraft:explosion_emitter", pos, 0.5, DVec3::ZERO, 0.0, if big { 3 } else { 1 });
let size = [2.5, 4.5, 7.0, 10.0, 13.0][power];
self.fireball(pos, size, [11, 11, 18, 22, 26][power]);
out.particles("minecraft:explosion_emitter", pos, 0.5 + power as f32, DVec3::ZERO, 0.0, [1, 1, 3, 5, 8][power]);
// The shader draws the fireball and smoke; particles add sparks and debris.
let n = if self.shader { 12 } else { 40 };
out.particles("minecraft:large_smoke", pos, size as f32 * 0.4, DVec3::new(0.0, 0.1, 0.0), 0.08, n);
@@ -1265,13 +1397,19 @@ impl Sim {
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 event = if big { "airdef:blast.big" } else if tnt > 0 { "airdef:blast.medium" } else { "airdef:blast.air" };
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 {
// A heavy warhead throws a column of dirt and a ring of dust.
out.particles("minecraft:campfire_cosy_smoke", pos, size as f32 * 0.5, DVec3::new(0.0, 0.15, 0.0), 0.05, 20 * power as u32);
out.particles("minecraft:explosion", pos, size as f32 * 0.6, DVec3::ZERO, 0.0, 10 * power as u32);
}
if let Some(p) = player {
let d = p.distance(pos);
let reach = if big { 100.0 } else { 45.0 };
let reach = [45.0, 45.0, 100.0, 160.0, 240.0][power];
if d < reach {
out.jolt((1.0 - d / reach) as f32 * if big { 0.9 } else { 0.4 }, if big { 1.6 } else { 0.8 });
let strength = [0.4, 0.4, 0.9, 1.3, 1.8][power];
out.jolt((1.0 - d / reach) as f32 * strength, [0.8, 0.8, 1.6, 2.2, 3.0][power]);
}
}
if self.damage && tnt > 0 {
@@ -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 1/3] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 1/5] 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 2/3] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 2/5] 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 3/3] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 3/5] 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
@@ -0,0 +1,48 @@
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 4/5] Mod models: draw far models as scaled impostors past the
far plane, cull at 4096
---
crates/horizon-client/src/game/mod_models.rs | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index be00ea4c7..6cd55afc6 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -120,14 +120,26 @@ impl Game<'_> {
return;
}
let t = alpha.clamp(0.0, 1.0);
+ // Missiles are seen from far off: past the far plane a model is drawn nearer and
+ // smaller, the same size on screen.
+ let depth_far = f64::from(horizon_client::camera::depth_far(
+ self.terrain_distance(),
+ self.settings.cloud_range,
+ ));
+ let reach = depth_far * 0.85;
for p in self.mods.models.poses.values() {
- let at = p.previous.lerp(p.position, t);
- // Far models are culled by distance; the view frustum does the rest.
- if at.distance_squared(camera) > 512.0 * 512.0 {
+ let mut at = p.previous.lerp(p.position, t);
+ let d = at.distance(camera);
+ if d > 4096.0 {
continue;
}
+ let mut shrink = 1.0f32;
+ if d > reach {
+ shrink = (reach / d) as f32;
+ at = camera + (at - camera) * (reach / d);
+ }
let rotation = p.old_rotation.slerp(p.rotation, t as f32);
- let scale = p.old_scale + (p.scale - p.old_scale) * t as f32;
+ let scale = (p.old_scale + (p.scale - p.old_scale) * t as f32) * shrink;
if scale <= 0.0 {
continue;
}
--
2.54.0 (Apple Git-157)
@@ -0,0 +1,927 @@
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 5/5] 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>
---
crates/horizon-client/src/game.rs | 3 +
crates/horizon-client/src/game/mod_models.rs | 39 ++-
crates/horizon-client/src/game/mod_render.rs | 9 +
crates/horizon-client/src/gfx.rs | 192 ++++++++++-
crates/horizon-client/src/gfx/mod_views.rs | 318 +++++++++++++++++++
crates/horizon-client/src/hud.rs | 15 +
crates/horizon-mod-sdk/wit/horizon.wit | 30 ++
crates/horizon-mods/src/api_level.rs | 5 +-
crates/horizon-mods/src/runtime.rs | 30 +-
9 files changed, 624 insertions(+), 17 deletions(-)
create mode 100644 crates/horizon-client/src/gfx/mod_views.rs
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index ca6a8c266..e5ffb30c4 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -11806,7 +11806,9 @@ impl Game<'_> {
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
+ let mod_views = self.mod_views(alpha);
let frame = Frame {
+ mod_views: &mod_views,
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
nether_lighting: self.dim == Dim::Nether,
@@ -14725,6 +14727,7 @@ fn loading_renderer(
/// A title-shaped frame: black clear and panorama, only the HUD is drawn.
fn loading_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
+ mod_views: &[],
weather: &[],
weather_time: horizon_client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index 6cd55afc6..7ae58f664 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -11,7 +11,7 @@ use {
particles::{ParticleOptions, ParticleType},
},
horizon_core::random::RandomSource,
- horizon_mods::ParticleSpawn,
+ horizon_mods::{CameraView, ParticleSpawn},
horizon_world::registry::BlockStateId,
std::collections::HashMap,
};
@@ -38,6 +38,8 @@ pub(super) struct ModModels {
states: HashMap<String, Option<BlockStateId>>,
/// The version of the mod post pass the renderer has (0: none).
post: u64,
+ /// The mods' cameras by id: a tick ago and now.
+ views: Vec<(CameraView, CameraView)>,
}
/// Full brightness: sky and block light 15, as the entity shader packs them.
@@ -55,6 +57,9 @@ impl Game<'_> {
) {
}
pub(super) fn apply_mod_post(&mut self, _partial: f32) {}
+ pub(super) fn mod_views(&self, _alpha: f64) -> Vec<horizon_client::gfx::ModView> {
+ Vec::new()
+ }
}
#[cfg(feature = "mods")]
@@ -111,6 +116,38 @@ impl Game<'_> {
let seen: std::collections::HashSet<_> = seen.into_iter().collect();
models.poses.retain(|k, _| seen.contains(k));
}
+ let views: Vec<CameraView> = self.mods.mods.views().map(|(_, v)| *v).collect();
+ let old = std::mem::take(&mut self.mods.models.views);
+ self.mods.models.views = views
+ .into_iter()
+ .map(|v| {
+ let before = old.iter().find(|(_, o)| o.id == v.id).map_or(v, |(_, o)| *o);
+ (before, v)
+ })
+ .collect();
+ }
+
+ /// The mods' cameras between their last two settings, for this frame.
+ pub(super) fn mod_views(&self, alpha: f64) -> Vec<horizon_client::gfx::ModView> {
+ let t = alpha.clamp(0.0, 1.0);
+ let lerp = |a: f32, b: f32| a + (b - a) * t as f32;
+ self.mods
+ .models
+ .views
+ .iter()
+ .map(|(a, b)| {
+ // The short way round.
+ let turn = (b.yaw - a.yaw + 540.0).rem_euclid(360.0) - 180.0;
+ horizon_client::gfx::ModView {
+ id: b.id,
+ camera: DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t),
+ yaw: a.yaw + turn * t as f32,
+ pitch: lerp(a.pitch, b.pitch),
+ fov: lerp(a.fov, b.fov),
+ size: [b.width, b.height],
+ }
+ })
+ .collect()
}
/// Draws every mod model between its last two poses, lit by the world
diff --git a/crates/horizon-client/src/game/mod_render.rs b/crates/horizon-client/src/game/mod_render.rs
index 931aec641..85aae67e7 100644
--- a/crates/horizon-client/src/game/mod_render.rs
+++ b/crates/horizon-client/src/game/mod_render.rs
@@ -120,6 +120,15 @@ impl Game<'_> {
self.draw_item_model(batch, &stack, x, y);
batch.scale_since(start, x, y, i.scale, i.scale);
}
+ Shape::View(v) => {
+ let r = v.rect;
+ batch.views.push(horizon_client::hud::HudView {
+ at: batch.vertices.len() as u32,
+ id: v.view,
+ rect: [ox + r.x, oy + r.y, r.w, r.h],
+ mode: v.mode,
+ });
+ }
Shape::Text(t) => {
let text = Text::literal(t.text.clone());
let centre = if t.centered {
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index 4c5005615..3ad257728 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -78,6 +78,8 @@ mod native_material;
mod native_pack;
mod native_post;
mod mod_post;
+mod mod_views;
+pub use mod_views::ModView;
mod native_sky;
mod occlusion;
mod pack_atlas;
@@ -214,6 +216,8 @@ const SECTION_UPLOAD_CHUNK: u64 = 4 << 20;
const SECTION_COPY_LIMIT: u64 = 64 << 20;
pub struct Frame<'a> {
pub camera: DVec3,
+ /// Mods' cameras (AirDef fork), drawn into the pictures the HUD shows.
+ pub mod_views: &'a [ModView],
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
@@ -546,6 +550,7 @@ pub struct Renderer {
native: Option<native_pack::NativePack>,
/// AirDef fork: a mod's post pass, its parameters and the partial tick they are for.
mod_post: Option<mod_post::ModPost>,
+ mod_views: Option<mod_views::Views>,
mod_params: (Vec<f32>, f32),
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
@@ -2651,6 +2656,7 @@ impl Renderer {
pack_post: None,
native: None,
mod_post: None,
+ mod_views: None,
mod_params: (Vec::new(), 0.0),
pass_timing: pack_timing::PassTiming::logging(),
pack_entity_rest: Vec::new(),
@@ -3019,6 +3025,128 @@ impl Renderer {
self.section_uploads.finish(&encoder);
Some(encoder.finish())
}
+ /// The HUD's vertices in `range`, with the mod cameras' pictures between
+ /// them where their shapes were.
+ fn draw_hud(
+ &self,
+ encoder: &mut wgpu::CommandEncoder,
+ target: &wgpu::TextureView,
+ batch: &crate::hud::HudBatch,
+ range: std::ops::Range<u32>,
+ ) {
+ let size = [self.config.width, self.config.height];
+ let last = batch.vertices.len() as u32;
+ let mut at = range.start;
+ if let Some(views) = &self.mod_views {
+ for v in batch.views.iter().filter(|v| {
+ v.at >= range.start && (v.at < range.end || (v.at == range.end && range.end == last))
+ }) {
+ self.hud.draw_range(encoder, target, &self.depth, batch, size, at..v.at);
+ views.blit(encoder, target, size, v, batch.scale);
+ at = v.at;
+ }
+ }
+ self.hud.draw_range(encoder, target, &self.depth, batch, size, at..range.end);
+ }
+ /// The mod cameras due this frame, each into its picture: sky, solid
+ /// terrain and entities, through the world's own pipelines with the
+ /// camera's globals in the world's buffer (the player's put back after).
+ fn draw_mod_views(
+ &mut self,
+ encoder: &mut wgpu::CommandEncoder,
+ frame: &Frame,
+ globals: &Globals,
+ due: &[mod_views::Due],
+ entity_quads: u32,
+ ) {
+ let Some(views) = &mut self.mod_views else {
+ return;
+ };
+ let size = std::mem::size_of::<Globals>() as u64;
+ self.uploads
+ .buffer(encoder, &views.main, 0, bytemuck::bytes_of(globals));
+ for d in due {
+ let Some(t) = views.targets.get(&d.id) else {
+ continue;
+ };
+ let own = Globals {
+ view_proj: d.view_proj.to_cols_array_2d(),
+ inverse_view: d.view.inverse().to_cols_array_2d(),
+ misc: [
+ globals.misc[0],
+ globals.misc[1],
+ d.aspect,
+ (d.fov.to_radians() / 2.0).tan(),
+ ],
+ ..*globals
+ };
+ self.uploads
+ .buffer(encoder, &t.globals, 0, bytemuck::bytes_of(&own));
+ }
+ let fog = globals.fog_color;
+ let clear = wgpu::Color {
+ r: f64::from(fog[0]),
+ g: f64::from(fog[1]),
+ b: f64::from(fog[2]),
+ a: 1.0,
+ };
+ for d in due {
+ let Some(t) = views.targets.get_mut(&d.id) else {
+ continue;
+ };
+ t.drawn = true;
+ encoder.copy_buffer_to_buffer(&t.globals, 0, &self.globals, 0, size);
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Mod view"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &t.color,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(clear),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: &t.depth,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(0.0),
+ store: wgpu::StoreOp::Discard,
+ }),
+ stencil_ops: None,
+ }),
+ ..Default::default()
+ });
+ pass.set_bind_group(0, &self.group, &[]);
+ if frame.skybox {
+ pass.set_pipeline(&self.sky);
+ pass.draw(0..3, 0..1);
+ }
+ pass.set_pipeline(&self.solid);
+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
+ pass.set_vertex_buffer(1, self.instances.slice(..));
+ let mut page = None;
+ for (k, v) in d.visible.iter().enumerate() {
+ let s = self.sections.get(v.slot);
+ if s.solid == 0 {
+ continue;
+ }
+ if page != Some(s.page) {
+ pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..));
+ page = Some(s.page);
+ }
+ let instance = d.first + k as u32;
+ pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
+ }
+ if entity_quads > 0 {
+ pass.set_pipeline(&self.entity_pipeline);
+ pass.set_bind_group(0, &self.entity_group, &[]);
+ pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
+ pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
+ }
+ }
+ encoder.copy_buffer_to_buffer(&views.main, 0, &self.globals, 0, size);
+ }
/// Draws `count` section draws from `first` with the arena and section
/// origins bound: one indirect multi-draw, or a loop without it.
/// Consecutive draws in one arena page share a vertex buffer binding,
@@ -3827,7 +3955,46 @@ impl Renderer {
});
}
self.drawn_sections = visible.len() + visible_lods.len();
- let instances = self.drawn_sections + shadow_visible.len();
+ // Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's.
+ let globals_size = std::mem::size_of::<Globals>() as u64;
+ let mut views_due = Vec::new();
+ if !frame.title && !frame.mod_views.is_empty() {
+ let format = self.config.format.remove_srgb_suffix();
+ let device = &self.device;
+ let views = self
+ .mod_views
+ .get_or_insert_with(|| mod_views::Views::new(device, format, globals_size));
+ let mut first = (self.drawn_sections + shadow_visible.len()) as u32;
+ let radius = ((frame.detail_distance / 16.0) as i32).min(mod_views::RADIUS);
+ for v in views.choose(device, frame.mod_views, globals_size) {
+ let (view_proj, view, aspect) = mod_views::matrices(v, camera, frame.depth_far);
+ let own = world_projection(v.fov, aspect, frame.depth_far) * view;
+ let planes = Frustum { planes: frustum(own) };
+ let mut seen = Vec::new();
+ self.sections
+ .visible(v.camera, radius, &planes, |_| true, |_, _| false, &mut seen);
+ // Relative to the player's camera, as the rest of the frame.
+ let shift = (v.camera - camera).as_vec3();
+ for s in &mut seen {
+ s.origin = [s.origin[0] + shift.x, s.origin[1] + shift.y, s.origin[2] + shift.z];
+ }
+ let n = seen.len() as u32;
+ views_due.push(mod_views::Due {
+ id: v.id,
+ view_proj,
+ view,
+ aspect,
+ fov: v.fov,
+ visible: seen,
+ first,
+ });
+ first += n;
+ }
+ } else if frame.mod_views.is_empty() {
+ self.mod_views = None;
+ }
+ let view_sections: usize = views_due.iter().map(|d| d.visible.len()).sum();
+ let instances = self.drawn_sections + shadow_visible.len() + view_sections;
if instances as u64 > self.instance_capacity {
self.instance_capacity = (instances as u64).next_power_of_two();
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -3845,7 +4012,12 @@ impl Renderer {
.iter()
.map(|v| section_instance(v.origin))
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
- .chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
+ .chain(shadow_visible.iter().map(|v| section_instance(v.origin)))
+ .chain(
+ views_due
+ .iter()
+ .flat_map(|d| d.visible.iter().map(|v| section_instance(v.origin))),
+ ),
);
if !self.origins.is_empty() {
self.uploads.buffer(
@@ -4461,6 +4633,9 @@ impl Renderer {
bytemuck::cast_slice(&self.draws),
);
}
+ if !views_due.is_empty() {
+ self.draw_mod_views(&mut encoder, frame, &globals, &views_due, entity_quads);
+ }
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(self.config.format.remove_srgb_suffix()),
..Default::default()
@@ -5167,14 +5342,7 @@ impl Renderer {
.menu_blur
.filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC));
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
- self.hud.draw_range(
- &mut encoder,
- &view_target,
- &self.depth,
- frame.hud,
- [self.config.width, self.config.height],
- 0..blur_at,
- );
+ self.draw_hud(&mut encoder, &view_target, frame.hud, 0..blur_at);
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
@@ -5190,12 +5358,10 @@ impl Renderer {
radius,
);
}
- self.hud.draw_range(
+ self.draw_hud(
&mut encoder,
&view_target,
- &self.depth,
frame.hud,
- [self.config.width, self.config.height],
blur_at..frame.hud.vertices.len() as u32,
);
if culling && let Some(cull) = &mut self.cull {
diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs
new file mode 100644
index 000000000..91ae103a8
--- /dev/null
+++ b/crates/horizon-client/src/gfx/mod_views.rs
@@ -0,0 +1,318 @@
+//! AirDef fork: mods' cameras (`render.set-views`). The world is drawn from
+//! another point into a small picture (sky, solid terrain, entities), which
+//! the HUD's `view` shapes show. The picture's passes reuse the world's
+//! pipelines and bind groups: its globals are copied into the world's
+//! buffer before them and the camera's copied back after.
+use glam::{DVec3, Mat4};
+use std::collections::HashMap;
+
+/// One camera of a mod, as the game interpolated it for this frame.
+#[derive(Clone, Copy, Debug)]
+pub struct ModView {
+ pub id: u32,
+ pub camera: DVec3,
+ /// Degrees, as the player's.
+ pub yaw: f32,
+ pub pitch: f32,
+ pub fov: f32,
+ pub size: [u32; 2],
+}
+
+pub use crate::hud::HudView;
+
+/// The most cameras drawn a frame; the others wait their turn.
+pub(super) const PER_FRAME: usize = 3;
+/// Sections round a camera it draws, in chunks.
+pub(super) const RADIUS: i32 = 12;
+
+pub(super) struct Target {
+ size: [u32; 2],
+ pub color: wgpu::TextureView,
+ pub depth: wgpu::TextureView,
+ pub globals: wgpu::Buffer,
+ blit: wgpu::BindGroup,
+ /// Drawn at least once: the HUD shows nothing before.
+ pub drawn: bool,
+}
+
+/// A camera drawn this frame.
+pub(super) struct Due {
+ pub id: u32,
+ /// Camera-relative to the player's camera, as the entities and the
+ /// world's other geometry are.
+ pub view_proj: Mat4,
+ pub view: Mat4,
+ pub aspect: f32,
+ pub fov: f32,
+ pub visible: Vec<super::resident::Visible>,
+ /// The instance of `visible[0]` in the section origins.
+ pub first: u32,
+}
+
+pub(super) struct Views {
+ pub targets: HashMap<u32, Target>,
+ /// The player's camera's globals, copied back after the pictures.
+ pub main: wgpu::Buffer,
+ cursor: usize,
+ format: wgpu::TextureFormat,
+ layout: wgpu::BindGroupLayout,
+ sampler: wgpu::Sampler,
+ pipeline: wgpu::RenderPipeline,
+}
+
+const BLIT: &str = r#"
+@group(0) @binding(0) var picture: texture_2d<f32>;
+@group(0) @binding(1) var picture_sampler: sampler;
+struct Out {
+ @builtin(position) position: vec4<f32>,
+ @location(0) uv: vec2<f32>,
+ @location(1) @interpolate(flat) mode: u32,
+}
+@vertex
+fn vs(@builtin(vertex_index) i: u32, @builtin(instance_index) mode: u32) -> Out {
+ let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
+ var out: Out;
+ out.position = vec4<f32>(uv * vec2<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0), 0.0, 1.0);
+ out.uv = uv;
+ out.mode = mode;
+ return out;
+}
+fn hash(p: vec2<f32>) -> f32 {
+ return fract(sin(dot(p, vec2<f32>(12.9898, 78.233))) * 43758.547);
+}
+@fragment
+fn fs(in: Out) -> @location(0) vec4<f32> {
+ let c = textureSample(picture, picture_sampler, in.uv).rgb;
+ let l = dot(c, vec3<f32>(0.299, 0.587, 0.114));
+ let grain = (hash(in.position.xy + vec2<f32>(c.r * 91.0, c.g * 37.0)) - 0.5) * 0.06;
+ if in.mode == 1u {
+ // White hot: the cold sky dark, the land grey, flames and engines burning white.
+ let warm = max(c.r - c.b, 0.0) * 2.2;
+ let cold = max(c.b - c.r, 0.0) * 2.5;
+ let glow = smoothstep(0.8, 1.0, max(c.r, c.g));
+ // High gain: a thermal camera sees as well by night.
+ let v = clamp(0.06 + pow(l, 0.4) * 0.55 + warm + glow * 0.9 - cold + grain, 0.0, 1.0);
+ return vec4<f32>(v, v * 0.98, v * 0.94, 1.0);
+ }
+ if in.mode == 2u {
+ let v = clamp(pow(l, 0.45) * 1.5 + grain, 0.0, 1.0);
+ return vec4<f32>(v * 0.3, v, v * 0.35, 1.0);
+ }
+ return vec4<f32>(c, 1.0);
+}
+"#;
+
+impl Views {
+ pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, globals_size: u64) -> Self {
+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("Mod view picture"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ view_dimension: wgpu::TextureViewDimension::D2,
+ multisampled: false,
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+ count: None,
+ },
+ ],
+ });
+ let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("Mod view blit"),
+ source: wgpu::ShaderSource::Wgsl(BLIT.into()),
+ });
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("Mod view blit"),
+ bind_group_layouts: &[Some(&layout)],
+ ..Default::default()
+ });
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("Mod view blit"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &module,
+ entry_point: Some("vs"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &module,
+ entry_point: Some("fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ primitive: Default::default(),
+ depth_stencil: None,
+ multisample: Default::default(),
+ multiview_mask: None,
+ cache: None,
+ });
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("Mod view picture"),
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ ..Default::default()
+ });
+ let main = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Camera globals"),
+ size: globals_size,
+ usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ Self {
+ targets: HashMap::new(),
+ main,
+ cursor: 0,
+ format,
+ layout,
+ sampler,
+ pipeline,
+ }
+ }
+
+ /// The cameras to draw this frame, in turn, at most `PER_FRAME`; their
+ /// targets made or remade to size, the gone ones' dropped.
+ pub fn choose<'a>(
+ &mut self,
+ device: &wgpu::Device,
+ views: &'a [ModView],
+ globals_size: u64,
+ ) -> Vec<&'a ModView> {
+ self.targets.retain(|id, _| views.iter().any(|v| v.id == *id));
+ for v in views {
+ if self.targets.get(&v.id).is_none_or(|t| t.size != v.size) {
+ let t = self.target(device, v.size, globals_size);
+ self.targets.insert(v.id, t);
+ }
+ }
+ if views.is_empty() {
+ return Vec::new();
+ }
+ let n = views.len().min(PER_FRAME);
+ let start = self.cursor % views.len();
+ self.cursor = self.cursor.wrapping_add(n);
+ (0..n).map(|k| &views[(start + k) % views.len()]).collect()
+ }
+
+ fn target(&self, device: &wgpu::Device, size: [u32; 2], globals_size: u64) -> Target {
+ let extent = wgpu::Extent3d {
+ width: size[0],
+ height: size[1],
+ depth_or_array_layers: 1,
+ };
+ let texture = |label, format, usage| {
+ device
+ .create_texture(&wgpu::TextureDescriptor {
+ label: Some(label),
+ size: extent,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format,
+ usage,
+ view_formats: &[],
+ })
+ .create_view(&Default::default())
+ };
+ let color = texture(
+ "Mod view colour",
+ self.format,
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+ );
+ let depth = texture(
+ "Mod view depth",
+ wgpu::TextureFormat::Depth32Float,
+ wgpu::TextureUsages::RENDER_ATTACHMENT,
+ );
+ let blit = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("Mod view picture"),
+ layout: &self.layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(&color),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ });
+ let globals = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Mod view globals"),
+ size: globals_size,
+ usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ Target {
+ size,
+ color,
+ depth,
+ globals,
+ blit,
+ drawn: false,
+ }
+ }
+
+ /// Shows a camera's picture in `rect` (framebuffer pixels) of `screen`.
+ pub fn blit(
+ &self,
+ encoder: &mut wgpu::CommandEncoder,
+ screen: &wgpu::TextureView,
+ screen_size: [u32; 2],
+ view: &HudView,
+ scale: f32,
+ ) {
+ let Some(t) = self.targets.get(&view.id).filter(|t| t.drawn) else {
+ return;
+ };
+ let [x, y, w, h] = view.rect.map(|v| v * scale);
+ let (x0, y0) = (x.max(0.0), y.max(0.0));
+ let x1 = (x + w).min(screen_size[0] as f32);
+ let y1 = (y + h).min(screen_size[1] as f32);
+ if x1 - x0 < 1.0 || y1 - y0 < 1.0 {
+ return;
+ }
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Mod view picture"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: screen,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ pass.set_viewport(x, y, w, h, 0.0, 1.0);
+ pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
+ pass.set_pipeline(&self.pipeline);
+ pass.set_bind_group(0, &t.blit, &[]);
+ let mode = view.mode.min(2);
+ pass.draw(0..3, mode..mode + 1);
+ }
+}
+
+/// A camera's view and projection: the view from `camera`, moved so that
+/// geometry relative to `eye` (the player's camera) lands right.
+pub(super) fn matrices(v: &ModView, eye: DVec3, far: f32) -> (Mat4, Mat4, f32) {
+ let aspect = v.size[0] as f32 / v.size[1].max(1) as f32;
+ let view = crate::camera::view_matrix(v.yaw, v.pitch);
+ let projection = super::world_projection(v.fov, aspect, far);
+ let offset = (eye - v.camera).as_vec3();
+ (projection * view * Mat4::from_translation(offset), view, aspect)
+}
diff --git a/crates/horizon-client/src/hud.rs b/crates/horizon-client/src/hud.rs
index fda0094d8..41af0650e 100644
--- a/crates/horizon-client/src/hud.rs
+++ b/crates/horizon-client/src/hud.rs
@@ -625,6 +625,17 @@ pub struct HudVertex {
}
/// The GUI Scale option (0 = auto).
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
+/// Where the HUD shows a mod camera's picture (AirDef fork): before its
+/// vertex `at`, over what was drawn before.
+#[derive(Clone, Copy, Debug)]
+pub struct HudView {
+ pub at: u32,
+ pub id: u32,
+ /// GUI pixels: x, y, width, height.
+ pub rect: [f32; 4],
+ /// 0 as seen, 1 thermal, 2 night vision.
+ pub mode: u32,
+}
/// One frame of overlay geometry in GUI pixels.
pub struct HudBatch {
pub vertices: Vec<HudVertex>,
@@ -641,6 +652,8 @@ pub struct HudBatch {
pub glint_alpha: f32,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
+ /// Mod cameras' pictures, in vertex order.
+ pub views: Vec<HudView>,
}
/// How a run of HUD vertices blends and tests depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -692,6 +705,7 @@ impl HudBatch {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
+ views: Vec::new(),
}
}
/// Draw the vertices added since `start` with the inverting blend.
@@ -2008,6 +2022,7 @@ mod tests {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
+ views: Vec::new(),
};
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
assert_eq!(
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 1b167c7f7..436455a19 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -338,12 +338,21 @@ interface render {
centered: bool,
shadow: bool,
}
+ /// A picture of one of the mod's `set-views` cameras (AirDef fork).
+ record view-shape {
+ /// The camera's id; an unknown one draws nothing.
+ view: u32,
+ rect: rect,
+ /// 0 as seen, 1 thermal (white hot), 2 night vision (green).
+ mode: u32,
+ }
variant shape {
fill(fill-shape),
quad(quad-shape),
sprite(sprite-shape),
item(item-shape),
text(text-shape),
+ view(view-shape),
}
/// Replaces the mod's HUD shapes: up to 1024, drawn every frame in
/// order over the HUD (after the hotbar, under chat and screens), hidden
@@ -500,6 +509,27 @@ interface render {
/// replaced; call it once a tick (the pass gets the partial tick in
/// `frame.time.y`).
set-params: func(params: list<f32>);
+ /// A camera the game renders the world from besides the player's eye
+ /// (AirDef fork): the land, the sky and the entities near the player.
+ record camera-view {
+ /// Stays the same while the camera lives: the game turns it smoothly
+ /// between the last two `set-views`.
+ id: u32,
+ x: f64,
+ y: f64,
+ z: f64,
+ /// Degrees, as the player's: yaw 0 looks south (+z), pitch 90 down.
+ yaw: f32,
+ pitch: f32,
+ /// Vertical field of view, degrees (1 to 110).
+ fov: f32,
+ /// The picture's size in pixels (16 to 640 × 16 to 480).
+ width: u32,
+ height: u32,
+ }
+ /// Replaces the mod's cameras: up to 8. A `view` shape shows one; the
+ /// game renders up to three a frame, in turn.
+ set-views: func(views: list<camera-view>);
}
/// What the game calls. Only the hooks `init` asks for are ever called.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 68487e600..9d15851b4 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 8;
+pub const LEVEL: u32 = 9;
/// Every function the game offers mods, as `interface.function`, with its
/// 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); 54] = [
+pub const FUNCTIONS: [(&str, u32); 55] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -69,6 +69,7 @@ pub const FUNCTIONS: [(&str, u32); 54] = [
("render.set-post", 8),
("render.clear-post", 8),
("render.set-params", 8),
+ ("render.set-views", 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 55c1fd13f..01000dfeb 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -81,7 +81,7 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I
pub use render_bindings::horizon::mods::render::{
Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape,
- Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom,
+ CameraView, Rect as RenderRect, Shape, SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
};
use render_bindings::{RenderHost, horizon::mods::render};
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
@@ -435,6 +435,8 @@ struct State {
/// `render.set-post`'s source, with a number that changes with it.
post: Option<(u64, String)>,
post_params: Vec<f32>,
+ /// `render.set-views`' cameras (AirDef fork).
+ views: Vec<CameraView>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -643,6 +645,7 @@ impl Mods {
ground: Vec::new(),
post: None,
post_params: Vec::new(),
+ views: Vec::new(),
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -1206,6 +1209,14 @@ impl Mods {
.flat_map(move |r| r.store.data().models.iter().map(move |m| (i, m)))
})
}
+ /// Every running mod's cameras (AirDef fork), with the mod's index.
+ pub fn views(&self) -> impl Iterator<Item = (usize, &CameraView)> {
+ self.entries.iter().enumerate().flat_map(|(i, e)| {
+ e.run
+ .iter()
+ .flat_map(move |r| r.store.data().views.iter().map(move |v| (i, v)))
+ })
+ }
/// The columns each running mod asked the ground height of, taken.
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
self.entries
@@ -1969,6 +1980,23 @@ impl render::Host for State {
.map(|v| if v.is_finite() { v } else { 0.0 })
.collect();
}
+ fn set_views(&mut self, views: Vec<CameraView>) {
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
+ let angle = |v: f32| if v.is_finite() { v } else { 0.0 };
+ self.views = views
+ .into_iter()
+ .filter(|v| finite(v.x) && finite(v.y) && finite(v.z))
+ .take(8)
+ .map(|mut v| {
+ v.yaw = angle(v.yaw);
+ v.pitch = angle(v.pitch).clamp(-90.0, 90.0);
+ v.fov = if v.fov.is_finite() { v.fov.clamp(1.0, 110.0) } else { 70.0 };
+ v.width = v.width.clamp(16, 640);
+ v.height = v.height.clamp(16, 480);
+ v
+ })
+ .collect();
+ }
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
let room = 512usize.saturating_sub(self.ground_queries.len());
self.ground_queries.extend(columns.into_iter().take(room));
--
2.54.0 (Apple Git-157)
+5 -1
View File
@@ -667,9 +667,12 @@ MODELS = {
"iskander": (iskander, "Искандер-М"),
"kinzhal": (kinzhal, "Кинжал"),
"tracer": (lambda: [el((7.6, 7.6, -16), (8.4, 8.4, 32), "tracer"), el((7.3, 7.3, -16), (8.7, 8.7, -4), "tracer_hot")], "Трассер"),
# Боевой блок «Орешника»: раскалённое ядро и длинный плазменный хвост.
"reentry": (lambda: [el((6.5, 6.5, -16), (9.5, 9.5, -8), "white"), el((7, 7, -8), (9, 9, 12), "tracer_hot"),
el((7.5, 7.5, 12), (8.5, 8.5, 32), "flame")], "Боевой блок"),
}
CAM_MODELS = ["shahed", "geran3", "lancet", "gerbera", "kh101", "kalibr", "kh22", "zircon", "iskander", "kinzhal"]
CAM_MODELS = ["shahed", "geran3", "lancet", "gerbera", "kh101", "kalibr", "kh22", "zircon", "iskander", "kinzhal", "reentry"]
# Предметы: (id, название, цвет иконки)
ITEMS = [
@@ -688,6 +691,7 @@ ITEMS = [
("launch_zircon", "Пуск: Циркон", (52, 54, 52)),
("launch_iskander", "Пуск: Искандер-М", (108, 110, 80)),
("launch_kinzhal", "Пуск: Кинжал", (60, 62, 60)),
("launch_oreshnik", "Пуск: Орешник", (230, 120, 40)),
("remover", "Свернуть ПВО", (200, 200, 200)),
]
+8 -1
View File
@@ -340,7 +340,12 @@ def blast(size):
"""Взрыв: хлопок, суббас с падением тона, огненный рёв, раскаты (size 0..1)."""
d = 3.0 + 4.0 * size
crack = nwave(0.006 + 0.006 * size) * 3.0
sub = boom(d, 75 - 25 * size, 26 - 6 * size, 0.06 + 0.06 * size, 0.5 + 0.9 * size) * 1.6
sub = boom(d, max(75 - 25 * size, 34), max(26 - 6 * size, 21), 0.06 + 0.06 * size, 0.5 + 0.9 * size) * 1.6
if size > 1.0:
# Тяжёлая боевая часть: второй удар (детонация основного заряда) и давящий инфразвук.
k = int(SR * 0.09)
second = np.concatenate([np.zeros(k), boom(d, 50, 22, 0.1, 1.4 * size) * 1.4])[: len(sub)]
sub = sub + second
fire = band(pink(d), 80, 2200)
fire = fire / (np.abs(fire).max() + 1e-9) * env(d, 0.004, 0.25 + 0.3 * size) * 0.8
roll = rumble(d, 1.2 + 2.0 * size, roll=3.0) * 1.2
@@ -381,6 +386,8 @@ SOUNDS = {
"gun.gepard": (lambda: gun(3, 0.056, 55, 1500, 1.6), 400, True),
"gun.pantsir": (lambda: gun(6, 0.034, 75, 2200, 1.4), 380, True),
"blast.big": (lambda: blast(1.0), 1000, True),
"blast.heavy": (lambda: blast(1.35), 1300, True),
"blast.huge": (lambda: blast(1.75), 1700, True),
"blast.medium": (lambda: blast(0.5), 700, True),
"blast.air": (blast_air, 800, True),
}