diff --git a/README.md b/README.md index d3804eb..9ded210 100644 --- a/README.md +++ b/README.md @@ -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 <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`; +- `/airdef deploy ` — поставить комплекс в 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 м); +у каждого уровня свой звук. | ПВО | Вооружение | Дальность | |---|---|---| diff --git a/airdef-mod/assets/airdef/blockstates/reentry.json b/airdef-mod/assets/airdef/blockstates/reentry.json new file mode 100644 index 0000000..035b2de --- /dev/null +++ b/airdef-mod/assets/airdef/blockstates/reentry.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "airdef:block/reentry" + } + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/items/cam_reentry.json b/airdef-mod/assets/airdef/items/cam_reentry.json new file mode 100644 index 0000000..abdd107 --- /dev/null +++ b/airdef-mod/assets/airdef/items/cam_reentry.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "airdef:item/cam_reentry" + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/items/launch_oreshnik.json b/airdef-mod/assets/airdef/items/launch_oreshnik.json new file mode 100644 index 0000000..8f8cd7e --- /dev/null +++ b/airdef-mod/assets/airdef/items/launch_oreshnik.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "airdef:item/launch_oreshnik" + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/lang/en_us.json b/airdef-mod/assets/airdef/lang/en_us.json index 86e89b9..078103a 100644 --- a/airdef-mod/assets/airdef/lang/en_us.json +++ b/airdef-mod/assets/airdef/lang/en_us.json @@ -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": "Боевой блок" } \ No newline at end of file diff --git a/airdef-mod/assets/airdef/lang/ru_ru.json b/airdef-mod/assets/airdef/lang/ru_ru.json index 86e89b9..078103a 100644 --- a/airdef-mod/assets/airdef/lang/ru_ru.json +++ b/airdef-mod/assets/airdef/lang/ru_ru.json @@ -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": "Боевой блок" } \ No newline at end of file diff --git a/airdef-mod/assets/airdef/models/block/reentry.json b/airdef-mod/assets/airdef/models/block/reentry.json new file mode 100644 index 0000000..5189c3d --- /dev/null +++ b/airdef-mod/assets/airdef/models/block/reentry.json @@ -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" + } + } + } + ] +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/models/item/cam_reentry.json b/airdef-mod/assets/airdef/models/item/cam_reentry.json new file mode 100644 index 0000000..19eddf7 --- /dev/null +++ b/airdef-mod/assets/airdef/models/item/cam_reentry.json @@ -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 + ] + } + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/models/item/launch_oreshnik.json b/airdef-mod/assets/airdef/models/item/launch_oreshnik.json new file mode 100644 index 0000000..fa60184 --- /dev/null +++ b/airdef-mod/assets/airdef/models/item/launch_oreshnik.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "airdef:item/launch_oreshnik" + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/sounds.json b/airdef-mod/assets/airdef/sounds.json index 04c51d3..a6777b9 100644 --- a/airdef-mod/assets/airdef/sounds.json +++ b/airdef-mod/assets/airdef/sounds.json @@ -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": [ { diff --git a/airdef-mod/assets/airdef/sounds/blast_air.ogg b/airdef-mod/assets/airdef/sounds/blast_air.ogg index 22d5862..d53430b 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_air.ogg and b/airdef-mod/assets/airdef/sounds/blast_air.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_air_far.ogg b/airdef-mod/assets/airdef/sounds/blast_air_far.ogg index ce3fa42..1c6cf94 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_air_far.ogg and b/airdef-mod/assets/airdef/sounds/blast_air_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_big.ogg b/airdef-mod/assets/airdef/sounds/blast_big.ogg index c30f02e..6ebddc3 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_big.ogg and b/airdef-mod/assets/airdef/sounds/blast_big.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_big_far.ogg b/airdef-mod/assets/airdef/sounds/blast_big_far.ogg index d2dee38..43b3387 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_big_far.ogg and b/airdef-mod/assets/airdef/sounds/blast_big_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_heavy.ogg b/airdef-mod/assets/airdef/sounds/blast_heavy.ogg new file mode 100644 index 0000000..3c448a0 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/blast_heavy.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_heavy_far.ogg b/airdef-mod/assets/airdef/sounds/blast_heavy_far.ogg new file mode 100644 index 0000000..9de9024 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/blast_heavy_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_huge.ogg b/airdef-mod/assets/airdef/sounds/blast_huge.ogg new file mode 100644 index 0000000..63a1cf4 Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/blast_huge.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_huge_far.ogg b/airdef-mod/assets/airdef/sounds/blast_huge_far.ogg new file mode 100644 index 0000000..f0e77de Binary files /dev/null and b/airdef-mod/assets/airdef/sounds/blast_huge_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_medium.ogg b/airdef-mod/assets/airdef/sounds/blast_medium.ogg index 787d59b..6c25680 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_medium.ogg and b/airdef-mod/assets/airdef/sounds/blast_medium.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/blast_medium_far.ogg b/airdef-mod/assets/airdef/sounds/blast_medium_far.ogg index d79cfc1..77865e6 100644 Binary files a/airdef-mod/assets/airdef/sounds/blast_medium_far.ogg and b/airdef-mod/assets/airdef/sounds/blast_medium_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/boom_sonic.ogg b/airdef-mod/assets/airdef/sounds/boom_sonic.ogg index 1e11ddc..07f5b42 100644 Binary files a/airdef-mod/assets/airdef/sounds/boom_sonic.ogg and b/airdef-mod/assets/airdef/sounds/boom_sonic.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/boom_sonic_far.ogg b/airdef-mod/assets/airdef/sounds/boom_sonic_far.ogg index c531bbd..1cce8e9 100644 Binary files a/airdef-mod/assets/airdef/sounds/boom_sonic_far.ogg and b/airdef-mod/assets/airdef/sounds/boom_sonic_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_ballistic.ogg b/airdef-mod/assets/airdef/sounds/drone_ballistic.ogg index 7034d78..d858d70 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_ballistic.ogg and b/airdef-mod/assets/airdef/sounds/drone_ballistic.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_cruise.ogg b/airdef-mod/assets/airdef/sounds/drone_cruise.ogg index 795fe9c..1a35729 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_cruise.ogg and b/airdef-mod/assets/airdef/sounds/drone_cruise.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_geran3.ogg b/airdef-mod/assets/airdef/sounds/drone_geran3.ogg index 013cafe..6ada909 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_geran3.ogg and b/airdef-mod/assets/airdef/sounds/drone_geran3.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_lancet.ogg b/airdef-mod/assets/airdef/sounds/drone_lancet.ogg index 413b8ba..78884f5 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_lancet.ogg and b/airdef-mod/assets/airdef/sounds/drone_lancet.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_rocket.ogg b/airdef-mod/assets/airdef/sounds/drone_rocket.ogg index 584bbf4..6528ef1 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_rocket.ogg and b/airdef-mod/assets/airdef/sounds/drone_rocket.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/drone_shahed.ogg b/airdef-mod/assets/airdef/sounds/drone_shahed.ogg index f8193fe..14d8ac3 100644 Binary files a/airdef-mod/assets/airdef/sounds/drone_shahed.ogg and b/airdef-mod/assets/airdef/sounds/drone_shahed.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/gun_gepard.ogg b/airdef-mod/assets/airdef/sounds/gun_gepard.ogg index 3a877fb..0ab88fe 100644 Binary files a/airdef-mod/assets/airdef/sounds/gun_gepard.ogg and b/airdef-mod/assets/airdef/sounds/gun_gepard.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/gun_gepard_far.ogg b/airdef-mod/assets/airdef/sounds/gun_gepard_far.ogg index cbf4a4f..0cc1786 100644 Binary files a/airdef-mod/assets/airdef/sounds/gun_gepard_far.ogg and b/airdef-mod/assets/airdef/sounds/gun_gepard_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/gun_pantsir.ogg b/airdef-mod/assets/airdef/sounds/gun_pantsir.ogg index eaeabd1..3abd46a 100644 Binary files a/airdef-mod/assets/airdef/sounds/gun_pantsir.ogg and b/airdef-mod/assets/airdef/sounds/gun_pantsir.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/gun_pantsir_far.ogg b/airdef-mod/assets/airdef/sounds/gun_pantsir_far.ogg index 5120d93..fd8fc56 100644 Binary files a/airdef-mod/assets/airdef/sounds/gun_pantsir_far.ogg and b/airdef-mod/assets/airdef/sounds/gun_pantsir_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_pantsir.ogg b/airdef-mod/assets/airdef/sounds/launch_pantsir.ogg index 0a132bf..0a0eb0c 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_pantsir.ogg and b/airdef-mod/assets/airdef/sounds/launch_pantsir.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_pantsir_far.ogg b/airdef-mod/assets/airdef/sounds/launch_pantsir_far.ogg index bde586c..7b708b2 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_pantsir_far.ogg and b/airdef-mod/assets/airdef/sounds/launch_pantsir_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_patriot.ogg b/airdef-mod/assets/airdef/sounds/launch_patriot.ogg index aa38313..12a1b25 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_patriot.ogg and b/airdef-mod/assets/airdef/sounds/launch_patriot.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_patriot_far.ogg b/airdef-mod/assets/airdef/sounds/launch_patriot_far.ogg index 2a40e97..627d43a 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_patriot_far.ogg and b/airdef-mod/assets/airdef/sounds/launch_patriot_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s400.ogg b/airdef-mod/assets/airdef/sounds/launch_s400.ogg index 8cda957..3832d6e 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s400.ogg and b/airdef-mod/assets/airdef/sounds/launch_s400.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s400_eject.ogg b/airdef-mod/assets/airdef/sounds/launch_s400_eject.ogg index f768196..10593b3 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s400_eject.ogg and b/airdef-mod/assets/airdef/sounds/launch_s400_eject.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s400_eject_far.ogg b/airdef-mod/assets/airdef/sounds/launch_s400_eject_far.ogg index dc6d324..3daffab 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s400_eject_far.ogg and b/airdef-mod/assets/airdef/sounds/launch_s400_eject_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s400_far.ogg b/airdef-mod/assets/airdef/sounds/launch_s400_far.ogg index 4e89e0c..8a6410f 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s400_far.ogg and b/airdef-mod/assets/airdef/sounds/launch_s400_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s500.ogg b/airdef-mod/assets/airdef/sounds/launch_s500.ogg index 66083bf..2186790 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s500.ogg and b/airdef-mod/assets/airdef/sounds/launch_s500.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s500_eject.ogg b/airdef-mod/assets/airdef/sounds/launch_s500_eject.ogg index 56d7d7a..ac333d4 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s500_eject.ogg and b/airdef-mod/assets/airdef/sounds/launch_s500_eject.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s500_eject_far.ogg b/airdef-mod/assets/airdef/sounds/launch_s500_eject_far.ogg index 94a0f4b..f9c127a 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s500_eject_far.ogg and b/airdef-mod/assets/airdef/sounds/launch_s500_eject_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/launch_s500_far.ogg b/airdef-mod/assets/airdef/sounds/launch_s500_far.ogg index 9cb55c2..03c9231 100644 Binary files a/airdef-mod/assets/airdef/sounds/launch_s500_far.ogg and b/airdef-mod/assets/airdef/sounds/launch_s500_far.ogg differ diff --git a/airdef-mod/assets/airdef/sounds/missile_flight.ogg b/airdef-mod/assets/airdef/sounds/missile_flight.ogg index 02c7b69..2a75abf 100644 Binary files a/airdef-mod/assets/airdef/sounds/missile_flight.ogg and b/airdef-mod/assets/airdef/sounds/missile_flight.ogg differ diff --git a/airdef-mod/assets/airdef/textures/item/launch_oreshnik.png b/airdef-mod/assets/airdef/textures/item/launch_oreshnik.png new file mode 100644 index 0000000..f45d56d Binary files /dev/null and b/airdef-mod/assets/airdef/textures/item/launch_oreshnik.png differ diff --git a/airdef-mod/assets/airdef/textures/item/remover.png b/airdef-mod/assets/airdef/textures/item/remover.png index 3b63f32..12e512b 100644 Binary files a/airdef-mod/assets/airdef/textures/item/remover.png and b/airdef-mod/assets/airdef/textures/item/remover.png differ diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 565dcda..605235d 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -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, monitor_shown: bool, + /// Every system's monitor in a grid (J). + grid: bool, /// The menu's page while it is open. menu: Option, /// 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); diff --git a/airdef-mod/src/monitor.rs b/airdef-mod/src/monitor.rs index e426eb7..92ee51a 100644 --- a/airdef-mod/src/monitor.rs +++ b/airdef-mod/src/monitor.rs @@ -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, x: f32, y: f32, w: f32, h: f32, len: f32, color: } } -pub fn draw(sim: &Sim, index: usize) -> Vec { - 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) { + 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 { } } 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 { } 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 { } 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 { + 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 { + 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 } diff --git a/airdef-mod/src/sim.rs b/airdef-mod/src/sim.rs index e9446fb..e2f2379 100644 --- a/airdef-mod/src/sim.rs +++ b/airdef-mod/src/sim.rs @@ -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 { @@ -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 { 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 = 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, 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 { 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 821bd5c..164673a 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/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; 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 cd96a1c..76d78d1 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/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 --- 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 0191494..5a39d02 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/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 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 new file mode 100644 index 0000000..116e2f6 --- /dev/null +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -0,0 +1,48 @@ +From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 +From: chexit +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) + 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 new file mode 100644 index 0000000..0300ce7 --- /dev/null +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -0,0 +1,927 @@ +From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 +From: chexit +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 +--- + 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>, + /// 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 { ++ 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 = 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 { ++ 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, + /// AirDef fork: a mod's post pass, its parameters and the partial tick they are for. + mod_post: Option, ++ mod_views: Option, + mod_params: (Vec, 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, ++ ) { ++ 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::() 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::() 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, ++ /// The instance of `visible[0]` in the section origins. ++ pub first: u32, ++} ++ ++pub(super) struct Views { ++ pub targets: HashMap, ++ /// 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; ++@group(0) @binding(1) var picture_sampler: sampler; ++struct Out { ++ @builtin(position) position: vec4, ++ @location(0) uv: vec2, ++ @location(1) @interpolate(flat) mode: u32, ++} ++@vertex ++fn vs(@builtin(vertex_index) i: u32, @builtin(instance_index) mode: u32) -> Out { ++ let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); ++ var out: Out; ++ out.position = vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); ++ out.uv = uv; ++ out.mode = mode; ++ return out; ++} ++fn hash(p: vec2) -> f32 { ++ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.547); ++} ++@fragment ++fn fs(in: Out) -> @location(0) vec4 { ++ let c = textureSample(picture, picture_sampler, in.uv).rgb; ++ let l = dot(c, vec3(0.299, 0.587, 0.114)); ++ let grain = (hash(in.position.xy + vec2(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(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(v * 0.3, v, v * 0.35, 1.0); ++ } ++ return vec4(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, +@@ -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, + } + /// 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); ++ /// 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); + } + + /// 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, ++ /// `render.set-views`' cameras (AirDef fork). ++ views: Vec, + 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 { ++ 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) { ++ 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) + diff --git a/tools/gen_models.py b/tools/gen_models.py index 1a24ffc..52eb56c 100644 --- a/tools/gen_models.py +++ b/tools/gen_models.py @@ -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)), ] diff --git a/tools/gen_sounds.py b/tools/gen_sounds.py index 7cac7f4..1f0da43 100644 --- a/tools/gen_sounds.py +++ b/tools/gen_sounds.py @@ -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), }