feat: add shared radar batteries and animated cold launch
build / mod (push) Failing after 18s

Separate search and engagement roles, support launcher-only SAMs, animate individual tube lids and delay ground strike spending until ejection. Preserve battery configuration and launch poses in saves and replay.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
2026-11-02 23:40:40 +03:00
parent d4adbb1e47
commit 966b52f9fc
41 changed files with 2534 additions and 509 deletions
+2 -1
View File
@@ -161,8 +161,9 @@ API мода `render.set-lights` (до 512, плавно интерполиру
- `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт;
- `/airdef lastchance <on|off>` — «последний шанс»: комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт (по умолчанию вкл; сохраняется);
- `/airdef sites` — известные пусковые площадки (ПУ). РЛС с прямой видимостью может заметить сам пуск и получить неточную позицию; несколько наблюдённых пусков уточняют её. Обнаруженный позже дрон даёт только слабое предположение об обратном курсе: скрытые координаты площадки и время невидимого старта не используются. Повторные треки одного полёта не создают ложную точность.
- `/airdef strike <номер ПУ>` — ручной удар по разведанной площадке из готового физического С-400 с отдельно загруженной RM48U (игровой профиль, #118). Четыре ячейки делятся с боезапасом ПВО; приказ списывает одну ударную ракету у носителя, а не создаёт её возле игрока. Выбирается готовый комплекс с наибольшим остатком RM48U, затем ближайший к цели. Есть выброс, отложенное включение двигателя, ограниченная игровая траектория, столкновения и запись пуска. Несуществующее «ПВО площадки» не перехватывает ракету по броску вероятности.
- `/airdef strike <номер ПУ>` — ручной удар по разведанной площадке из готового физического С-400 с отдельно загруженной RM48U (игровой профиль, #118). Четыре ячейки делятся с боезапасом ПВО; приказ резервирует носитель и ячейку, открывает крышку и списывает одну ударную ракету при выбросе. Выбирается готовый комплекс с наибольшим остатком RM48U, затем ближайший к цели. Есть открытие ТПК, выброс, отложенное включение двигателя и разгон, ограниченная игровая траектория, столкновения и запись пуска. Несуществующее «ПВО площадки» не перехватывает ракету по броску вероятности.
- `/airdef ground-stock <номер склада> <количество>` — задать начальный запас RM48U для sandbox-сценария (по умолчанию 0, сам не пополняется). `/airdef ground-load <номер С-400>` — у живого склада в пределах 60 блоков загрузить одну RM48U, сохранив снятую ракету ПВО на складе; `/airdef ground-unload <номер С-400>` — вернуть её и восстановить снятую ракету ПВО, если она осталась. Запас сохраняется с миром. Автоматическая доставка RM48U через ТЗМ пока не реализована. Совместимость профиля основана на [публикации ГУР](https://gur.gov.ua/content/warsanctions-hur-mo-rozkryvaie-budovu-komponentnu-bazu-ta-pidpryiemstva-kooperatsii-z-vyrobnytstva-udarnykh-raket-rm48u.html); это сообщение ведомства, не независимая сертификация. Дальность, форма полёта и оформление условны для Minecraft.
- `/airdef deploy s400_launcher` и `s500_launcher` — отдельные пусковые без своей РЛС; `radar_search`, `radar_track`, `radar_height` — обзорная, сопровождающая и мачтовая игровые РЛС. Они также доступны через меню N → «Разместить элементы батареи…». `/airdef battery` показывает состав и связь; `/airdef battery <номер> integrated|launcher` меняет конфигурацию, `/airdef radar <номер> multi|search|engagement|height` — роль отдельной РЛС. Обзорная РЛС передаёт подсказки; станция сопровождения должна сама получить свежее измерение, чтобы разрешить пуск. Старые С-400/С-500 сохраняют упрощённую сборную конфигурацию. [Устройство игровой батареи и пуска](docs/BATTERIES-AND-COLD-LAUNCH.md).
- `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
- `/airdef doctrine` — доктрины комплексов (#47; в меню N: «Доктрины…»): `/airdef doctrine <номер|all> reserve <K>` — держать K ракет для срочных целей (баллистика или до удара меньше 12 с); `salvo eco|normal|heavy` — сколько перехватчиков на цель (только базовые / как обычно / на один больше); `sector <центр°> <полуширина°>` или `sector off` — сектор ответственности (0° — север); `guard <объект№>[,<№>]` или `guard off` — стрелять только по тому, что идёт на охраняемые объекты (отметка: `/airdef target here`). По умолчанию ограничений нет; последний шанс сильнее доктрины; `/airdef why <номер>` называет, какая настройка остановила или разрешила пуск; сохраняется с миром и сценариями. Карта охраны и пробелов в ней — вместе с картой (#48).
- `/airdef deploy <patriot|gepard|pantsir|s400|s500>` — поставить комплекс в 8 блоках перед собой;
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/radar_mast"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/radar_mast_burnt"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/tpk_cap"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/tpk_cap_burnt"
}
}
}
+4
View File
@@ -30,6 +30,8 @@
"block.airdef.s400_tubes": "ТПК 48Н6",
"block.airdef.s500_chassis": "Шасси С-500",
"block.airdef.s500_tubes": "ТПК 77Н6",
"block.airdef.tpk_cap": "Крышка ТПК (анимация)",
"block.airdef.radar_mast": "Радиомачта (секция высоты)",
"block.airdef.radar_trailer": "Прицеп РЛС",
"block.airdef.radar_panel": "РЛС обзора",
"block.airdef.m57e6": "57Э6",
@@ -77,6 +79,8 @@
"block.airdef.s400_tubes_burnt": "ТПК 48Н6 (остов)",
"block.airdef.s500_chassis_burnt": "Шасси С-500 (остов)",
"block.airdef.s500_tubes_burnt": "ТПК 77Н6 (остов)",
"block.airdef.tpk_cap_burnt": "Крышка ТПК (анимация) (остов)",
"block.airdef.radar_mast_burnt": "Радиомачта (секция высоты) (остов)",
"block.airdef.irist_chassis_burnt": "Шасси IRIS-T SLM (остов)",
"block.airdef.irist_tubes_burnt": "Пакет IRIS-T (остов)",
"block.airdef.nasams_trailer_burnt": "Прицеп NASAMS (остов)",
+4
View File
@@ -30,6 +30,8 @@
"block.airdef.s400_tubes": "ТПК 48Н6",
"block.airdef.s500_chassis": "Шасси С-500",
"block.airdef.s500_tubes": "ТПК 77Н6",
"block.airdef.tpk_cap": "Крышка ТПК (анимация)",
"block.airdef.radar_mast": "Радиомачта (секция высоты)",
"block.airdef.radar_trailer": "Прицеп РЛС",
"block.airdef.radar_panel": "РЛС обзора",
"block.airdef.m57e6": "57Э6",
@@ -77,6 +79,8 @@
"block.airdef.s400_tubes_burnt": "ТПК 48Н6 (остов)",
"block.airdef.s500_chassis_burnt": "Шасси С-500 (остов)",
"block.airdef.s500_tubes_burnt": "ТПК 77Н6 (остов)",
"block.airdef.tpk_cap_burnt": "Крышка ТПК (анимация) (остов)",
"block.airdef.radar_mast_burnt": "Радиомачта (секция высоты) (остов)",
"block.airdef.irist_chassis_burnt": "Шасси IRIS-T SLM (остов)",
"block.airdef.irist_tubes_burnt": "Пакет IRIS-T (остов)",
"block.airdef.nasams_trailer_burnt": "Прицеп NASAMS (остов)",
@@ -0,0 +1,418 @@
{
"ambientocclusion": false,
"textures": {
"dark_metal": "airdef:block/dark_metal",
"metal": "airdef:block/metal",
"particle": "airdef:block/dark_metal"
},
"elements": [
{
"from": [
4.0,
0.0,
4.0
],
"to": [
12.0,
3.0,
12.0
],
"faces": {
"north": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
8.0,
8.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
8.0,
8.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
5.5,
3.0,
5.5
],
"to": [
10.5,
18.0,
10.5
],
"faces": {
"north": {
"uv": [
0,
0,
5.0,
15.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
5.0,
15.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
5.0,
15.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
5.0,
15.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
5.0,
5.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
5.0,
5.0
],
"texture": "#metal"
}
}
},
{
"from": [
4.5,
18.0,
4.5
],
"to": [
11.5,
21.0,
11.5
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
3.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
7.0,
3.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
7.0,
3.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
7.0,
3.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
7.0,
7.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
7.0,
7.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
6.5,
21.0,
6.5
],
"to": [
9.5,
30.0,
9.5
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
9.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
3.0,
9.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
3.0,
9.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
3.0,
9.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
5.5,
30.0,
5.5
],
"to": [
10.5,
32.0,
10.5
],
"faces": {
"north": {
"uv": [
0,
0,
5.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
5.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
5.0,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
5.0,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
5.0,
5.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
5.0,
5.0
],
"texture": "#metal"
}
}
},
{
"from": [
7.0,
31.5,
7.0
],
"to": [
9.0,
32.0,
9.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
0.5
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
0.5
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
0.5
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
0.5
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
}
]
}
@@ -0,0 +1,418 @@
{
"ambientocclusion": false,
"textures": {
"dark_metal_b": "airdef:block/dark_metal_b",
"metal_b": "airdef:block/metal_b",
"particle": "airdef:block/dark_metal_b"
},
"elements": [
{
"from": [
4.0,
0.0,
4.0
],
"to": [
12.0,
3.0,
12.0
],
"faces": {
"north": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#dark_metal_b"
},
"south": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#dark_metal_b"
},
"east": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#dark_metal_b"
},
"west": {
"uv": [
0,
0,
8.0,
3.0
],
"texture": "#dark_metal_b"
},
"up": {
"uv": [
0,
0,
8.0,
8.0
],
"texture": "#dark_metal_b"
},
"down": {
"uv": [
0,
0,
8.0,
8.0
],
"texture": "#dark_metal_b"
}
}
},
{
"from": [
5.5,
3.0,
5.5
],
"to": [
10.5,
18.0,
10.5
],
"faces": {
"north": {
"uv": [
0,
0,
5.0,
15.0
],
"texture": "#metal_b"
},
"south": {
"uv": [
0,
0,
5.0,
15.0
],
"texture": "#metal_b"
},
"east": {
"uv": [
0,
0,
5.0,
15.0
],
"texture": "#metal_b"
},
"west": {
"uv": [
0,
0,
5.0,
15.0
],
"texture": "#metal_b"
},
"up": {
"uv": [
0,
0,
5.0,
5.0
],
"texture": "#metal_b"
},
"down": {
"uv": [
0,
0,
5.0,
5.0
],
"texture": "#metal_b"
}
}
},
{
"from": [
4.5,
18.0,
4.5
],
"to": [
11.5,
21.0,
11.5
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
3.0
],
"texture": "#dark_metal_b"
},
"south": {
"uv": [
0,
0,
7.0,
3.0
],
"texture": "#dark_metal_b"
},
"east": {
"uv": [
0,
0,
7.0,
3.0
],
"texture": "#dark_metal_b"
},
"west": {
"uv": [
0,
0,
7.0,
3.0
],
"texture": "#dark_metal_b"
},
"up": {
"uv": [
0,
0,
7.0,
7.0
],
"texture": "#dark_metal_b"
},
"down": {
"uv": [
0,
0,
7.0,
7.0
],
"texture": "#dark_metal_b"
}
}
},
{
"from": [
6.5,
21.0,
6.5
],
"to": [
9.5,
30.0,
9.5
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
9.0
],
"texture": "#dark_metal_b"
},
"south": {
"uv": [
0,
0,
3.0,
9.0
],
"texture": "#dark_metal_b"
},
"east": {
"uv": [
0,
0,
3.0,
9.0
],
"texture": "#dark_metal_b"
},
"west": {
"uv": [
0,
0,
3.0,
9.0
],
"texture": "#dark_metal_b"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#dark_metal_b"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#dark_metal_b"
}
}
},
{
"from": [
5.5,
30.0,
5.5
],
"to": [
10.5,
32.0,
10.5
],
"faces": {
"north": {
"uv": [
0,
0,
5.0,
2.0
],
"texture": "#metal_b"
},
"south": {
"uv": [
0,
0,
5.0,
2.0
],
"texture": "#metal_b"
},
"east": {
"uv": [
0,
0,
5.0,
2.0
],
"texture": "#metal_b"
},
"west": {
"uv": [
0,
0,
5.0,
2.0
],
"texture": "#metal_b"
},
"up": {
"uv": [
0,
0,
5.0,
5.0
],
"texture": "#metal_b"
},
"down": {
"uv": [
0,
0,
5.0,
5.0
],
"texture": "#metal_b"
}
}
},
{
"from": [
7.0,
31.5,
7.0
],
"to": [
9.0,
32.0,
9.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
0.5
],
"texture": "#dark_metal_b"
},
"south": {
"uv": [
0,
0,
2.0,
0.5
],
"texture": "#dark_metal_b"
},
"east": {
"uv": [
0,
0,
2.0,
0.5
],
"texture": "#dark_metal_b"
},
"west": {
"uv": [
0,
0,
2.0,
0.5
],
"texture": "#dark_metal_b"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal_b"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal_b"
}
}
}
]
}
@@ -19,15 +19,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -87,15 +78,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -155,15 +137,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -223,15 +196,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.3999999999999995
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -291,15 +255,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -495,15 +450,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -563,15 +509,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -631,15 +568,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
1.3999999999999986
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -699,15 +627,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -767,15 +686,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -971,15 +881,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1039,15 +940,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1107,15 +999,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1175,15 +1058,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.3999999999999995
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1243,15 +1117,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1447,15 +1312,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1515,15 +1371,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1583,15 +1430,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
1.3999999999999986
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1651,15 +1489,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1719,15 +1548,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -19,15 +19,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
9.5,
9.5
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -87,15 +78,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
9.5,
9.5
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -19,15 +19,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
9.5,
9.5
],
"texture": "#tube_cap_b"
},
"south": {
"uv": [
0,
@@ -87,15 +78,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
9.5,
9.5
],
"texture": "#tube_cap_b"
},
"south": {
"uv": [
0,
@@ -0,0 +1,282 @@
{
"ambientocclusion": false,
"textures": {
"tube_cap": "airdef:block/tube_cap",
"dark_metal": "airdef:block/dark_metal",
"particle": "airdef:block/tube_cap"
},
"elements": [
{
"from": [
2.5,
0.0,
7.7
],
"to": [
13.5,
16.0,
8.25
],
"faces": {
"north": {
"uv": [
0,
0,
11.0,
16
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
0,
11.0,
16
],
"texture": "#tube_cap"
},
"east": {
"uv": [
0,
0,
0.5499999999999998,
16
],
"texture": "#tube_cap"
},
"west": {
"uv": [
0,
0,
0.5499999999999998,
16
],
"texture": "#tube_cap"
},
"up": {
"uv": [
0,
0,
11.0,
0.5499999999999998
],
"texture": "#tube_cap"
},
"down": {
"uv": [
0,
0,
11.0,
0.5499999999999998
],
"texture": "#tube_cap"
}
}
},
{
"from": [
0.0,
2.5,
7.7
],
"to": [
2.5,
13.5,
8.25
],
"faces": {
"north": {
"uv": [
0,
0,
2.5,
11.0
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
0,
2.5,
11.0
],
"texture": "#tube_cap"
},
"east": {
"uv": [
0,
0,
0.5499999999999998,
11.0
],
"texture": "#tube_cap"
},
"west": {
"uv": [
0,
0,
0.5499999999999998,
11.0
],
"texture": "#tube_cap"
},
"up": {
"uv": [
0,
0,
2.5,
0.5499999999999998
],
"texture": "#tube_cap"
},
"down": {
"uv": [
0,
0,
2.5,
0.5499999999999998
],
"texture": "#tube_cap"
}
}
},
{
"from": [
13.5,
2.5,
7.7
],
"to": [
16.0,
13.5,
8.25
],
"faces": {
"north": {
"uv": [
0,
0,
2.5,
11.0
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
0,
2.5,
11.0
],
"texture": "#tube_cap"
},
"east": {
"uv": [
0,
0,
0.5499999999999998,
11.0
],
"texture": "#tube_cap"
},
"west": {
"uv": [
0,
0,
0.5499999999999998,
11.0
],
"texture": "#tube_cap"
},
"up": {
"uv": [
0,
0,
2.5,
0.5499999999999998
],
"texture": "#tube_cap"
},
"down": {
"uv": [
0,
0,
2.5,
0.5499999999999998
],
"texture": "#tube_cap"
}
}
},
{
"from": [
5.0,
5.0,
8.25
],
"to": [
11.0,
11.0,
8.3
],
"faces": {
"north": {
"uv": [
0,
0,
6.0,
6.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
6.0,
6.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
0.25,
6.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
0.25,
6.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
6.0,
0.25
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
6.0,
0.25
],
"texture": "#dark_metal"
}
}
}
]
}
@@ -0,0 +1,282 @@
{
"ambientocclusion": false,
"textures": {
"tube_cap_b": "airdef:block/tube_cap_b",
"dark_metal_b": "airdef:block/dark_metal_b",
"particle": "airdef:block/tube_cap_b"
},
"elements": [
{
"from": [
2.5,
0.0,
7.7
],
"to": [
13.5,
16.0,
8.25
],
"faces": {
"north": {
"uv": [
0,
0,
11.0,
16
],
"texture": "#tube_cap_b"
},
"south": {
"uv": [
0,
0,
11.0,
16
],
"texture": "#tube_cap_b"
},
"east": {
"uv": [
0,
0,
0.5499999999999998,
16
],
"texture": "#tube_cap_b"
},
"west": {
"uv": [
0,
0,
0.5499999999999998,
16
],
"texture": "#tube_cap_b"
},
"up": {
"uv": [
0,
0,
11.0,
0.5499999999999998
],
"texture": "#tube_cap_b"
},
"down": {
"uv": [
0,
0,
11.0,
0.5499999999999998
],
"texture": "#tube_cap_b"
}
}
},
{
"from": [
0.0,
2.5,
7.7
],
"to": [
2.5,
13.5,
8.25
],
"faces": {
"north": {
"uv": [
0,
0,
2.5,
11.0
],
"texture": "#tube_cap_b"
},
"south": {
"uv": [
0,
0,
2.5,
11.0
],
"texture": "#tube_cap_b"
},
"east": {
"uv": [
0,
0,
0.5499999999999998,
11.0
],
"texture": "#tube_cap_b"
},
"west": {
"uv": [
0,
0,
0.5499999999999998,
11.0
],
"texture": "#tube_cap_b"
},
"up": {
"uv": [
0,
0,
2.5,
0.5499999999999998
],
"texture": "#tube_cap_b"
},
"down": {
"uv": [
0,
0,
2.5,
0.5499999999999998
],
"texture": "#tube_cap_b"
}
}
},
{
"from": [
13.5,
2.5,
7.7
],
"to": [
16.0,
13.5,
8.25
],
"faces": {
"north": {
"uv": [
0,
0,
2.5,
11.0
],
"texture": "#tube_cap_b"
},
"south": {
"uv": [
0,
0,
2.5,
11.0
],
"texture": "#tube_cap_b"
},
"east": {
"uv": [
0,
0,
0.5499999999999998,
11.0
],
"texture": "#tube_cap_b"
},
"west": {
"uv": [
0,
0,
0.5499999999999998,
11.0
],
"texture": "#tube_cap_b"
},
"up": {
"uv": [
0,
0,
2.5,
0.5499999999999998
],
"texture": "#tube_cap_b"
},
"down": {
"uv": [
0,
0,
2.5,
0.5499999999999998
],
"texture": "#tube_cap_b"
}
}
},
{
"from": [
5.0,
5.0,
8.25
],
"to": [
11.0,
11.0,
8.3
],
"faces": {
"north": {
"uv": [
0,
0,
6.0,
6.0
],
"texture": "#dark_metal_b"
},
"south": {
"uv": [
0,
0,
6.0,
6.0
],
"texture": "#dark_metal_b"
},
"east": {
"uv": [
0,
0,
0.25,
6.0
],
"texture": "#dark_metal_b"
},
"west": {
"uv": [
0,
0,
0.25,
6.0
],
"texture": "#dark_metal_b"
},
"up": {
"uv": [
0,
0,
6.0,
0.25
],
"texture": "#dark_metal_b"
},
"down": {
"uv": [
0,
0,
6.0,
0.25
],
"texture": "#dark_metal_b"
}
}
}
]
}
@@ -0,0 +1,4 @@
{
"template": "minecraft:stone",
"item": false
}
@@ -0,0 +1,4 @@
{
"template": "minecraft:stone",
"item": false
}
@@ -0,0 +1,4 @@
{
"template": "minecraft:stone",
"item": false
}
@@ -0,0 +1,4 @@
{
"template": "minecraft:stone",
"item": false
}
+18 -6
View File
@@ -63,6 +63,8 @@ c3b0b6f1d9b7da434c95f2794b97b9e2c05c62ccbce9f0884df071e4ec28a468 assets/airdef/
3ecae997dc4fe1939848fc60347979c02075396a164b31dec3995a2e907a8447 assets/airdef/blockstates/patriot_trailer.json
31fecc6476129990a7d368f6d7cdb954b13e777b28d1396a68168770024112ce assets/airdef/blockstates/patriot_trailer_burnt.json
183d0b2ae9c24c94c3d83f2819e1167b5e2d134cc7889e0e3bda12aa784c7baa assets/airdef/blockstates/patriot_truck.json
421b5be0a4c80e892850f4a77d5ebb386e04153201be63241c7edc11659d03de assets/airdef/blockstates/radar_mast.json
fcc6bbe526baf5551923e2a69cdf09f506cdfd96669e1fffbea643401dd2a1de assets/airdef/blockstates/radar_mast_burnt.json
37714e19e555cc50a9f195e1a8845a28c9985c27ed38b577c0813f4629b66fcb assets/airdef/blockstates/radar_panel.json
95fe14b9d3e30597f5505aa93f31c3357cebea6117596bd9f5a5bc840ab22415 assets/airdef/blockstates/radar_trailer.json
a95a1b8731d6bedda7d8b352ff3c448ea8be691b0ead01f14acab39234590c45 assets/airdef/blockstates/radar_truck.json
@@ -88,6 +90,8 @@ e766e18158d00f0480f6dfe32dfe142890b8d452bc493b7474fda0984cc2dd4c assets/airdef/
ff125150e206ada5a7fb47394e809039d338c22a843207a32352e9ee59e278a3 assets/airdef/blockstates/tor_chassis_burnt.json
04a5fb931f419086995791c1defd9486b36b3c725581bf5a10191a2c71c69984 assets/airdef/blockstates/tor_turret.json
fdcbe36a0f4968005e8c6588f2fa5d8196c55da3183cf89760ea221a5c4cb431 assets/airdef/blockstates/tor_turret_burnt.json
923340f2233c363a880fffeb40dba04038f26ff42b072f28418e0a6ab757aac5 assets/airdef/blockstates/tpk_cap.json
f3e430750f5cb60f44c2a97687d7cc1ac58232b27274a82758c23861d704662a assets/airdef/blockstates/tpk_cap_burnt.json
cf7c26abeb1d8f7d84106bd83b84bf541a3d11369b7745a77802b7e51dcdf447 assets/airdef/blockstates/tracer.json
8b547ccbc890fa53b613cdb2cf55ceaa5833348479b0863651c6df01a9f778b3 assets/airdef/blockstates/tzm_truck.json
11fca31a88e658c819954fbb86b53d7709b66b971af8747a5edda24be34e5dbb assets/airdef/blockstates/tzm_truck_burnt.json
@@ -132,8 +136,8 @@ b4d98a938486d49db28c6c7a608ca3b6ab0c9c3eac266ecf12c7dd931cc28f93 assets/airdef/
489799dcb5b441e9d4be8bfb3d73a6ebd5fdf2b5ee614b62f38b85bb2fbfad75 assets/airdef/items/launch_zircon.json
6b6da5887aa8d9bc987b2c181c243bc2afdd5846a869818f699b59204e559929 assets/airdef/items/mover.json
177f7535e4c52ed975d6f737c2b2acb7741b7a928955c8899aced85211443bbf assets/airdef/items/remover.json
e96a4968089443d4128ddb883905113018533f024d143cb79ae9f6d664aee2d4 assets/airdef/lang/en_us.json
e96a4968089443d4128ddb883905113018533f024d143cb79ae9f6d664aee2d4 assets/airdef/lang/ru_ru.json
cede33ba58f61891a8afe42e1f4311cb293ca93f4d7eb3a57636caff17317a4d assets/airdef/lang/en_us.json
cede33ba58f61891a8afe42e1f4311cb293ca93f4d7eb3a57636caff17317a4d assets/airdef/lang/ru_ru.json
b9b5b5bec33a77e944552c225ee2f526ab9820fa8fca4b3ccf311f2a325925ee assets/airdef/models/block/amraam_missile.json
c54e6b24ab69e9d9a2dbbbc63fe2ac60fa37735bf644500ddd5c3de0b91908cf assets/airdef/models/block/barge.json
a4198ff647de5866919f9b82a3d53e6486fc56cc9f175f7907951d063a6f4526 assets/airdef/models/block/beam.json
@@ -199,6 +203,8 @@ d01efdcfafa92243e745aea7870269c07a0224c9010010e2dbed720ced1662c5 assets/airdef/
548bc63e46d96e5110598e39269851c48f750164981a433f75971b0229c268df assets/airdef/models/block/patriot_trailer.json
190726b64bef4baadb0bc2a00147069685ef148100c8935402e09c3bef66e65b assets/airdef/models/block/patriot_trailer_burnt.json
6f027b020e4066f673c376e289cc627846a084e5733d62744fe78ab23013fb08 assets/airdef/models/block/patriot_truck.json
8070d24b511ac733d73f5961762e6f5995379814f33f54c56c1a45d1dd7ff28a assets/airdef/models/block/radar_mast.json
7343f6085138f974d581706cc135e582040a46069c215e95ba14ad31dc1a8d63 assets/airdef/models/block/radar_mast_burnt.json
e5733ece9b976998c9c6a6c968b17cada868b62d9e2a7bc67e1272e333e35399 assets/airdef/models/block/radar_panel.json
b0ae344fbad3b982e7821cd1b26f9922e2a5713b38107521ddc5a5b2abd9e39c assets/airdef/models/block/radar_trailer.json
5545094620c27fae6b3d97a71f3d68ee8e0b49804a1dcd61b79ca847277e3b61 assets/airdef/models/block/radar_truck.json
@@ -207,12 +213,12 @@ f102765390762d45954b26405b6acdde845df7f0a319bac53d25508e768957b5 assets/airdef/
971c11e58eafa17ad798f6d27d91a9ee0531f33692eb657d31f2d83e8cee7e39 assets/airdef/models/block/rm48u.json
a6bb637011c176edae474ae7b5d6e3b5af0d2a448845e3f95b026f789e21560f assets/airdef/models/block/s400_chassis.json
192076b361a57ee26a1dd449717f30f50b612ee6c88d4dc1fb9dc6aef3f3b829 assets/airdef/models/block/s400_chassis_burnt.json
7b2cac0485b4242534e99bbfc0406ee393d508ca971d4d88caf3f13892b473c1 assets/airdef/models/block/s400_tubes.json
44bf27184842d0e3c86bed035a7090add0a79f6b5bdea7a37e6ddaaae728d39a assets/airdef/models/block/s400_tubes.json
973e02b2058c0b3d59c156f2ac94fbba4ac90a97736e8a4cc9be177b5e0f19a9 assets/airdef/models/block/s400_tubes_burnt.json
088f42bb17f4d6099c16adf6cc0426b6d04ff9a43abdf125476a3571e924fcbd assets/airdef/models/block/s500_chassis.json
3e35ed19ea22c8309ac7c0ccc9c12e31b2387de97c6b72ba519bae34f681c58c assets/airdef/models/block/s500_chassis_burnt.json
6ce00881e2a5107874e82e94a243fcd95c3eb6e9a24e8839c11227c8e330485f assets/airdef/models/block/s500_tubes.json
88109407ec749d6f45a4a38a9a43fd1594ac6190d7f08433b07da572c040458d assets/airdef/models/block/s500_tubes_burnt.json
8c6bc46647f3df32febd96fccdba894e4c986b717b006e3c3e5607a73f0f252f assets/airdef/models/block/s500_tubes.json
135d34c4059ea7aca794cce201ef27c3c17cdb21ea27205634cea95bc8d16515 assets/airdef/models/block/s500_tubes_burnt.json
3d61559d5bde298d6dc8987122c68edc3b02bd0d1afed1d9458bd7c3315fb0fe assets/airdef/models/block/searchlight.json
6391c155f45656f63ac579bd1ff0d3512c54830a03561b8dd04e5e63ed751cf4 assets/airdef/models/block/searchlight_burnt.json
357165a1a1ad382257e68768bf1b4158294e0e080eab9a9570ed6bd824e7f3aa assets/airdef/models/block/searchlight_head.json
@@ -224,6 +230,8 @@ d5b9ed76b1744d0c93a809b6a4c16bc3dfa7ff4784e3cb9968aa36b12fc30484 assets/airdef/
cacaa7265489f1083f7de351515a7d08eac5d2b072cf58b32ee51840473ab44e assets/airdef/models/block/tor_chassis_burnt.json
d130179a7717f1bff9f045a3e0b93788eff17737c7c1c9c3b5b287d811eb6fad assets/airdef/models/block/tor_turret.json
a98d2a545b4bdea5cff1c5826d55dc8c3fd341ecebeb8b00ee2e1e1b0739b2cc assets/airdef/models/block/tor_turret_burnt.json
a6785198c98f5b2f20acc26ddeca3b5432e6b521b68541ea65ca2592c3cae4a1 assets/airdef/models/block/tpk_cap.json
0391ce9eeac9aa0cfcc05b31b0bd5594e10a5df2dc46c355b0b95a8ea8234ff5 assets/airdef/models/block/tpk_cap_burnt.json
b2380510f56dc60e914919cb6e20b82e3613a9d62b08a1c81740f960569c3425 assets/airdef/models/block/tracer.json
753c46e821b4fce9fd9240de2ec627a27688740a4c31c9eff24593034ffa33e7 assets/airdef/models/block/tzm_truck.json
fa29c0224a5a7ad2e3252cde099b17c6c8241ede7cf0e52766734672843a6432 assets/airdef/models/block/tzm_truck_burnt.json
@@ -508,6 +516,8 @@ cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/ho
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_trailer.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_trailer_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_truck.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_mast.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_mast_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_panel.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_trailer.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_truck.json
@@ -533,6 +543,8 @@ cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/ho
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_chassis_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_turret.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_turret_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tpk_cap.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tpk_cap_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tracer.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tzm_truck.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tzm_truck_burnt.json
@@ -577,4 +589,4 @@ cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/ho
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_zircon.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/mover.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/remover.json
3f7b0b03582f1c63f814259b898213006f3fbdc52489bad3f7b938e2ec1e5706 src/sim/generated_bounds.rs
5e0c0090b5aaded5b86b245cb30bcd0cbaa82f632a362d490d79bcf2794e25fd src/sim/generated_bounds.rs
@@ -19,15 +19,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -87,15 +78,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -155,15 +137,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -223,15 +196,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.3999999999999995
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -291,15 +255,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -495,15 +450,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -563,15 +509,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -631,15 +568,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
1.3999999999999986
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -699,15 +627,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -767,15 +686,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -971,15 +881,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1039,15 +940,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1107,15 +999,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1175,15 +1058,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.3999999999999995
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1243,15 +1117,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1447,15 +1312,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1515,15 +1371,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1583,15 +1430,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
7.0,
1.3999999999999986
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1651,15 +1489,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
6.440000000000001,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
@@ -1719,15 +1548,6 @@
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.199999999999999,
1.4000000000000004
],
"texture": "#tube_cap"
},
"south": {
"uv": [
0,
+5 -3
View File
@@ -14,7 +14,7 @@ fn on_off() -> Value {
/// `/airdef ...` as `lib.rs` accepts it.
pub fn tree() -> Tree {
let wave_kinds: Vec<&str> = DroneKind::ALL.iter().map(|k| k.spec().key).chain(["mix"]).collect();
let deploy_kinds: Vec<&str> = SamKind::ALL.iter().map(|k| k.key()).collect();
let deploy_kinds: Vec<&str> = SamKind::ALL.iter().map(|k| k.key()).chain(["s400_launcher", "s500_launcher", "radar_search", "radar_track", "radar_height"]).collect();
let armed: Vec<&str> = SamKind::ARMED.iter().map(|k| k.key()).chain(["all"]).collect();
let toggle = |word: &str| Node::lit(word).then(Node::arg("on/off", on_off()).runs());
Tree::new("airdef").runs().with([
@@ -70,6 +70,8 @@ pub fn tree() -> Tree {
Node::lit("move").then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("target").then(Node::lit("here").runs()),
Node::lit("give").runs(),
Node::lit("battery").runs().then(Node::arg("номер", Value::int_from(1)).then(Node::arg("режим", Value::choice(["integrated", "launcher"])).runs())),
Node::lit("radar").then(Node::arg("номер", Value::int_from(1)).then(Node::arg("роль", Value::choice(["multi", "search", "engagement", "height"])).runs())),
Node::lit("deploy").then(Node::arg("комплекс", Value::choice(deploy_kinds)).runs()),
Node::lit("wave").then(
Node::arg("тип", Value::choice(wave_kinds)).runs().then(
@@ -164,7 +166,7 @@ mod tests {
"replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop",
"scenario save a", "scenario save a fresh", "scenario load a", "scenario load a force", "scenario list",
"scenario delete a", "why", "why 2", "ew on", "ew off", "move 4", "target here", "targets clear",
"sites", "strike 3", "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000",
"sites", "strike 3", "battery", "battery 1 launcher", "battery 1 integrated", "radar 2 engagement", "radar 2 search", "deploy s400_launcher", "deploy s500_launcher", "deploy radar_search", "deploy radar_track", "deploy radar_height", "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000",
"wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", "lastchance on", "lastchance off",
"roles all default", "doctrine", "doctrine 1 reserve 4", "doctrine all salvo eco", "doctrine 2 sector 90 45", "doctrine 2 sector off", "doctrine 1 guard 1,2", "doctrine 1 reset", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
] {
@@ -240,7 +242,7 @@ mod tests {
"target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "lastchance", "roles", "doctrine", "stats", "sites", "strike", "ground-stock", "ground-load", "ground-unload"] {
assert!(top.contains(&w.to_string()), "{w} in {top:?}");
}
assert_eq!(top.len(), 31);
assert_eq!(top.len(), 33);
assert_eq!(complete("rep", &[]), ["repair", "replay"]);
assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]);
assert_eq!(complete("replay film ", &[]), ["all", "best"]);
+36 -1
View File
@@ -26,6 +26,9 @@ pub struct SamRec {
pub ground_rounds: u32,
pub ground_stock: u32,
pub air_48n6_stock: u32,
pub mode: sim::battery::SamMode,
pub radar_role: sim::battery::RadarRole,
pub spent_cells: u8,
}
#[derive(Default)]
@@ -123,13 +126,31 @@ impl Layout {
let ground_rounds = l.num(22, "заряжено RM48U", 0.0, 4.0)?.unwrap_or(0.0) as u32;
let ground_stock = l.num(23, "запас RM48U", 0.0, COUNT)?.unwrap_or(0.0) as u32;
let air_48n6_stock = l.num(24, "снятый боезапас ПВО", 0.0, COUNT)?.unwrap_or(0.0) as u32;
let mode = match l.w.get(25) {
None => sim::battery::SamMode::Integrated,
Some(v) => sim::battery::SamMode::from_key(v).ok_or_else(|| l.bad("режим", "integrated или launcher"))?,
};
let radar_role = match l.w.get(26) {
None => sim::battery::RadarRole::Multifunction,
Some(v) => sim::battery::RadarRole::from_key(v).ok_or_else(|| l.bad("роль РЛС", "multi, search, engagement или height"))?,
};
let spent_cells = match l.w.get(27) {
None => 0,
Some(v) => v.parse::<u8>().map_err(|_| l.bad("пустые контейнеры", "нужно целое число 0..255"))?,
};
if radar_role != sim::battery::RadarRole::Multifunction && kind != SamKind::Radar {
return Err(l.bad("роль РЛС", "роль поддерживается только отдельной РЛС"));
}
if mode == sim::battery::SamMode::LauncherOnly && !matches!(kind, SamKind::S400 | SamKind::S500) {
return Err(l.bad("режим", "пусковая поддерживается только С-400 и С-500"));
}
if (kind != SamKind::S400 && ground_rounds != 0) || (kind != SamKind::Depot && (ground_stock != 0 || air_48n6_stock != 0)) {
return Err(l.bad("боезапас", "несовместимый тип техники"));
}
if let Some((m, _, _, _)) = ammo && m + ground_rounds > 4 && kind == SamKind::S400 {
return Err(l.bad("боезапас", "больше четырёх ячеек"));
}
out.sams.push(SamRec { kind, pos, heading, roles, health, cargo, ammo, dest, comps, ground_rounds, ground_stock, air_48n6_stock });
out.sams.push(SamRec { kind, pos, heading, roles, health, cargo, ammo, dest, comps, ground_rounds, ground_stock, air_48n6_stock, mode, radar_role, spent_cells });
}
"target" => {
if out.targets.len() >= MAX_UNITS {
@@ -178,6 +199,20 @@ impl Layout {
mod tests {
use super::*;
#[test]
fn battery_modes_load_and_old_layouts_default() {
let old = Layout::parse("sam s400 0 70 0 0").unwrap();
assert_eq!(old.sams[0].mode, sim::battery::SamMode::Integrated);
assert_eq!(old.sams[0].radar_role, sim::battery::RadarRole::Multifunction);
let data = "sam s400 0 70 0 0 30 1 0 4 0 0 0 - - - 1 1 1 1 1 1 0 0 0 launcher multi 5\nsam radar 1 70 0 0 0 1 0 0 0 0 0 - - - 1 1 1 1 1 1 0 0 0 integrated engagement";
let l = Layout::parse(data).unwrap();
assert_eq!(l.sams[0].mode, sim::battery::SamMode::LauncherOnly);
assert_eq!(l.sams[0].spent_cells, 5);
assert_eq!(l.sams[1].radar_role, sim::battery::RadarRole::Engagement);
assert!(Layout::parse(&data.replace("engagement", "invalid")).is_err());
assert!(Layout::parse(&data.replace("launcher multi 5", "launcher multi 256")).is_err());
}
fn key() -> &'static str {
SamKind::Patriot.key()
}
+123 -20
View File
@@ -187,6 +187,7 @@ fn travels(event: &str) -> bool {
#[derive(Clone, Copy, PartialEq)]
enum Page {
Main,
BatteryDeploy,
Roles(SamKind),
/// The raid's make-up, then its parameters and the launch.
Wave,
@@ -201,6 +202,27 @@ enum Page {
}
const MENU_KINDS: u32 = 1;
const BATTERY_ROWS: u32 = 5;
const BTN_BATTERY: u32 = 22;
const BATTERY_DEPLOY: [(&str, &str); 5] = [
("s400_launcher", "С-400 — отдельная пусковая"),
("s500_launcher", "С-500 — отдельная пусковая"),
("radar_search", "РЛС обзора"),
("radar_track", "РЛС сопровождения"),
("radar_height", "Мачтовая РЛС"),
];
fn deploy_configuration(key: &str) -> Option<(SamKind, sim::battery::SamMode, sim::battery::RadarRole)> {
use sim::battery::{RadarRole as R, SamMode as M};
Some(match key {
"s400_launcher" => (SamKind::S400, M::LauncherOnly, R::Multifunction),
"s500_launcher" => (SamKind::S500, M::LauncherOnly, R::Multifunction),
"radar_search" => (SamKind::Radar, M::Integrated, R::Search),
"radar_track" => (SamKind::Radar, M::Integrated, R::Engagement),
"radar_height" => (SamKind::Radar, M::Integrated, R::Height),
_ => (SamKind::from_key(key)?, M::Integrated, R::Multifunction),
})
}
const BTN_HOLD: u32 = 10;
const BTN_SHADER: u32 = 11;
const BTN_DAMAGE: u32 = 12;
@@ -875,7 +897,7 @@ impl AirDef {
let comps = if fresh { new.comps } else { s.comps };
let parts = comps.0.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>().join(" ");
text += &format!(
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest} {parts} {} {} {}\n",
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest} {parts} {} {} {} {} {} {}\n",
s.kind.key(),
s.pos.x,
s.pos.y,
@@ -888,7 +910,7 @@ impl AirDef {
bursts,
u8::from(empty),
reloading,
s.ground_rounds, s.ground_stock, s.air_48n6_stock,
s.ground_rounds, s.ground_stock, s.air_48n6_stock, s.mode.key(), s.radar_role.key(), if fresh { 0 } else { s.spent_cells },
);
}
for (i, t) in self.sim.targets.iter().enumerate() {
@@ -1000,6 +1022,10 @@ impl AirDef {
sam.ground_stock = r.ground_stock;
sam.air_48n6_stock = r.air_48n6_stock;
sam.fit_cells();
sam.set_mode(r.mode);
sam.set_radar_role(r.radar_role);
sam.spent_cells = r.spent_cells;
sam.sync_tube_cells();
self.sim.sams.push(sam);
if let Some(to) = r.dest {
let i = self.sim.sams.len() - 1;
@@ -1093,7 +1119,7 @@ fn durable_mark(sim: &Sim) -> u64 {
let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49;
let typed = u64::from(s.ground_rounds) ^ u64::from(s.ground_stock).rotate_left(17) ^ u64::from(s.air_48n6_stock).rotate_left(39);
let parts = s.comps.0.iter().fold(0u64, |a, v| a.wrapping_mul(131).wrapping_add((v * 100.0) as u64));
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13) ^ typed.rotate_left(29)
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13) ^ typed.rotate_left(29) ^ (s.mode as u64).rotate_left(3) ^ (s.radar_role as u64).rotate_left(11) ^ u64::from(s.spent_cells).rotate_left(19)
})
.chain(sim.target_health.iter().map(|h| (h * 10.0) as u64))
.fold(sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h))
@@ -1458,6 +1484,23 @@ impl AirDef {
};
}
fn deploy_configured(&mut self, key: &str) -> Result<String, String> {
let (kind, mode, role) = deploy_configuration(key).ok_or("Неизвестный вид техники")?;
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 id = self.sim.id();
let mut sam = Sam::new(id, kind, at, (-flat.x).atan2(-flat.z));
sam.set_mode(mode);
sam.set_radar_role(role);
if let Some(r) = self.sim.roles.get(&kind) { sam.roles = *r; }
self.sim.sams.push(sam);
self.sim.settle(id);
self.dirty = true;
Ok(format!("{} развёрнут ({}, {})", kind.name(), mode.key(), role.key()))
}
fn open_menu(&mut self, page: Page) {
self.menu = Some(page);
let onoff = |b: bool| if b { "вкл" } else { "выкл" };
@@ -1476,6 +1519,7 @@ impl AirDef {
.collect();
widgets.push(Widget::List(ListWidget { id: MENU_KINDS, rows, selected: None }));
let _ = &self.monitor;
widgets.push(Widget::Button(ButtonWidget { id: BTN_BATTERY, label: "Разместить элементы батареи…".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_GRID, label: "Командный пункт: все комплексы (J)".into() }));
let custom = self.sim.sams.iter().filter(|s| !s.doctrine.is_default()).count();
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOCTRINE, label: format!("Доктрины: охрана, сектор, резерв (особых: {custom})…") }));
@@ -1483,6 +1527,11 @@ impl AirDef {
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPLAYS, label: "Повторы…".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_MORE, label: "Огонь, ремонт, эффекты…".into() }));
}
Page::BatteryDeploy => {
title = "Размещение батареи — клик создаёт технику перед игроком".into();
widgets.push(Widget::List(ListWidget { id: BATTERY_ROWS, rows: BATTERY_DEPLOY.iter().map(|(_, name)| (*name).to_string()).collect(), selected: None }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
Page::Replays => {
match self.sim.replays.last() {
Some(rec) => {
@@ -1999,6 +2048,23 @@ impl Mod for AirDef {
}
return;
}
(Page::Main, BTN_BATTERY) => {
self.open_menu(Page::BatteryDeploy);
return;
}
(Page::BatteryDeploy, BATTERY_ROWS) => {
if let Some((key, _)) = BATTERY_DEPLOY.get(row as usize) {
match self.deploy_configured(key) {
Ok(message) => say(&message),
Err(error) => say(&error),
}
}
return;
}
(Page::BatteryDeploy, BTN_BACK) => {
self.open_menu(Page::Main);
return;
}
(Page::Main, BTN_DOCTRINE) => {
self.open_menu(Page::Fleet);
return;
@@ -2207,24 +2273,27 @@ impl Mod for AirDef {
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, irist, nasams, buk, tor, p1sun, radar, depot, tzm, ew, light")?;
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);
// Standing in the ground (a chunk that had not loaded as the player fell): it comes up to the surface.
self.sim.settle(sid);
["deploy", kind] => Some(self.deploy_configured(kind)?),
["battery"] => {
let status = self.sim.battery_status();
for line in &status { say(line); }
Some("/airdef battery <номер> integrated|launcher; /airdef radar <номер> multi|search|engagement|height".into())
}
["battery", n, mode] => {
let i = n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).ok_or("Нужен номер комплекса")?;
let mode = sim::battery::SamMode::from_key(mode).ok_or("Режим: integrated или launcher")?;
let sam = self.sim.sams.get_mut(i).ok_or("Нет такого комплекса")?;
if !sam.set_mode(mode) { return Err("Отдельная пусковая доступна для С-400 и С-500".into()); }
self.dirty = true;
Some(format!("{} развёрнут", kind.name()))
Some(format!("№{} {}: режим {}", i + 1, sam.kind.name(), mode.key()))
}
["radar", n, role] => {
let i = n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).ok_or("Нужен номер РЛС")?;
let role = sim::battery::RadarRole::from_key(role).ok_or("Роль: multi, search, engagement или height")?;
let sam = self.sim.sams.get_mut(i).ok_or("Нет такого комплекса")?;
if !sam.set_radar_role(role) { return Err("Нужна отдельная РЛС".into()); }
self.dirty = true;
Some(format!("№{} РЛС: роль {}", i + 1, role.key()))
}
["wave", kind, rest @ ..] => {
let count = rest.first().and_then(|v| v.parse().ok()).unwrap_or(5u32).clamp(1, 80);
@@ -2450,6 +2519,24 @@ horizon_mod_sdk::export_mod!(AirDef);
#[cfg(test)]
mod tests {
#[test]
fn battery_menu_presets_leave_existing_catalog_indices_intact() {
use super::*;
assert_eq!(sim::catalog::INVENTORY[0], "airdef:designator");
assert_eq!(sim::catalog::UNITS[3].kind, SamKind::S400);
assert_eq!(sim::catalog::UNITS[4].kind, SamKind::S500);
assert_eq!(sim::catalog::UNITS[10].kind, SamKind::Radar);
for (key, _) in BATTERY_DEPLOY {
assert!(deploy_configuration(key).is_some());
}
for kind in SamKind::ALL {
let (actual, mode, role) = deploy_configuration(kind.key()).unwrap();
assert_eq!(actual, kind);
assert_eq!(mode, sim::battery::SamMode::Integrated);
assert_eq!(role, sim::battery::RadarRole::Multifunction);
}
}
use super::*;
/// #83: while an export records no other replay or film replaces it; after it, they may.
@@ -2465,6 +2552,22 @@ mod tests {
assert_ne!(durable_mark(&sim), before);
}
#[test]
fn battery_configuration_and_spent_cells_change_durable_mark() {
let mut sim = Sim::new(1);
sim.sams.push(Sam::new(1, SamKind::S400, DVec3::ZERO, 0.0));
let before = durable_mark(&sim);
sim.sams[0].set_mode(sim::battery::SamMode::LauncherOnly);
assert_ne!(durable_mark(&sim), before);
let before = durable_mark(&sim);
sim.sams[0].spent_cells = 1;
assert_ne!(durable_mark(&sim), before);
sim.sams.push(Sam::new(2, SamKind::Radar, DVec3::ZERO, 0.0));
let before = durable_mark(&sim);
sim.sams[1].set_radar_role(sim::battery::RadarRole::Search);
assert_ne!(durable_mark(&sim), before);
}
#[test]
fn nothing_replaces_a_running_export() {
assert!(switch_allowed(true).is_err());
+7 -2
View File
@@ -2322,6 +2322,11 @@ impl AirDef {
let Some((n, sam)) = self.sim.sams.iter().enumerate().find(|(_, s)| s.id == id) else { return };
let k = sam.kind;
lines.push((format!("{} №{}", k.name(), n + 1), CYAN));
if k == SamKind::Radar {
lines.push((format!("Роль РЛС: {}", sam.radar_role.name()), WHITE));
} else if sam.mode == sim::battery::SamMode::LauncherOnly {
lines.push(("Отдельная пусковая: внешняя РЛС сопровождения".into(), WHITE));
}
lines.push((sam.status(), WHITE));
lines.push((format!("Исправен {:.0}% · {:.0} {:.0}", sam.health * 100.0, sam.pos.x, sam.pos.z), GREY));
if k.armed() {
@@ -2329,8 +2334,8 @@ impl AirDef {
lines.push((format!("Боезапас: {} ракет, {} очередей{}", sam.missiles, sam.bursts, if sam.reloading > 0 { format!(" · перезарядка {} с", sam.reloading / 20) } else { String::new() }), WHITE));
lines.push((format!("Дальность огня: {}", distance(k.range())), WHITE));
}
if k.spec().sensor > 0.0 {
lines.push((format!("Свой обзор: {} (зелёное кольцо)", distance(k.spec().sensor)), WHITE));
if let Some(sensor) = self.sim.low_sensor(k, sam.pos, Some(sam.id)) {
lines.push((format!("Свой обзор для низкой цели: {} (зелёное кольцо)", distance(sensor.reach)), WHITE));
}
if let Some(t) = sam.gun_target.or(sam.target) {
lines.push((format!("Ведёт цель: трек №{t}"), YELLOW));
+3 -3
View File
@@ -13,7 +13,7 @@ pub enum Source<'a> {
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MoverKind {
Drone(sim::DroneKind),
Missile(sim::MissileKind),
Missile { kind: sim::MissileKind, engine_lit: bool },
Strike { physical: bool, engine_lit: bool },
}
@@ -135,7 +135,7 @@ impl<'a> Snapshot<'a> {
.chain(sim.missiles.iter().map(|m| Mover {
id: m.id,
group: 1,
kind: MoverKind::Missile(m.kind),
kind: MoverKind::Missile { kind: m.kind, engine_lit: m.engine_lit() },
pos: m.pos,
}))
.chain(sim.strikes.flights.iter().map(|f| {
@@ -160,7 +160,7 @@ impl<'a> Snapshot<'a> {
let pos = tr.at(rec.origin, self.time)?;
let (group, kind) = match tr.what {
sim::record::What::Drone(kind) => (0, MoverKind::Drone(kind)),
sim::record::What::Missile(kind, _) => (1, MoverKind::Missile(kind)),
sim::record::What::Missile(kind, _) => (1, MoverKind::Missile { kind, engine_lit: rec.missile_is_lit(tr, self.time, kind.lit_after()) }),
sim::record::What::Strike => (
2,
MoverKind::Strike {
+257
View File
@@ -0,0 +1,257 @@
//! Game-only configuration for a launcher using shared radar tracks.
use super::*;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SamMode {
#[default]
Integrated,
LauncherOnly,
}
impl SamMode {
pub fn key(self) -> &'static str {
match self { Self::Integrated => "integrated", Self::LauncherOnly => "launcher" }
}
pub fn from_key(key: &str) -> Option<Self> {
match key { "integrated" => Some(Self::Integrated), "launcher" => Some(Self::LauncherOnly), _ => None }
}
}
/// Game roles for the generic shared radar; all parameters are fictional.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RadarRole {
#[default]
Multifunction,
Search,
Engagement,
Height,
}
impl RadarRole {
pub fn key(self) -> &'static str {
match self { Self::Multifunction => "multi", Self::Search => "search", Self::Engagement => "engagement", Self::Height => "height" }
}
pub fn from_key(key: &str) -> Option<Self> {
match key { "multi" => Some(Self::Multifunction), "search" => Some(Self::Search), "engagement" => Some(Self::Engagement), "height" => Some(Self::Height), _ => None }
}
pub fn name(self) -> &'static str {
match self { Self::Multifunction => "многофункциональная", Self::Search => "обзорная", Self::Engagement => "сопровождения", Self::Height => "мачтовая" }
}
pub fn authorizes(self) -> bool { matches!(self, Self::Multifunction | Self::Engagement) }
pub(super) fn reach_factor(self) -> f64 { match self { Self::Multifunction => 1.0, Self::Search => 1.15, Self::Engagement => 0.8, Self::Height => 1.05 } }
}
impl Sam {
pub fn set_radar_role(&mut self, role: RadarRole) -> bool {
if self.kind != SamKind::Radar { return false; }
if self.radar_role != role {
self.radar_role = role;
self.tracks.clear();
self.seen.clear();
self.own.clear();
}
true
}
/// Change the physical configuration. Invalid combinations leave the unit untouched.
pub fn set_mode(&mut self, mode: SamMode) -> bool {
if mode == SamMode::LauncherOnly && !matches!(self.kind, SamKind::S400 | SamKind::S500) { return false; }
if self.mode != mode {
self.mode = mode;
self.tracks.clear();
self.seen.clear();
self.own.clear();
self.target = None;
self.gun_target = None;
self.terrain.clear();
self.waits.clear();
}
true
}
}
impl Sim {
/// Current configurations and whether a launcher has an operational radar link.
pub fn battery_status(&self) -> Vec<String> {
self.sams.iter().enumerate().filter_map(|(i, sam)| {
if sam.kind == SamKind::Radar {
Some(format!("№{} РЛС: {}, {}", i + 1, sam.radar_role.name(), if sam.radar_up() && sam.link_range() > 0.0 { "работает" } else { "нет связи или РЛС не работает" }))
} else if matches!(sam.kind, SamKind::S400 | SamKind::S500) {
let linked = self.sams.iter().any(|r| r.kind == SamKind::Radar && r.radar_role.authorizes() && r.radar_up() && r.link_range() > 0.0 && sam.link_range() > 0.0 && r.pos.distance(sam.pos) <= r.link_range().min(sam.link_range()));
Some(format!("№{} {}: {}{}", i + 1, sam.kind.name(), sam.mode.key(), if sam.mode == SamMode::LauncherOnly { if linked { ", РЛС связана" } else { ", нет связанной РЛС сопровождения" } } else { "" }))
} else { None }
}).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::{sight, why};
fn track(now: u64) -> Track {
Track { id: 42, kind: DroneKind::Kh101, pos: DVec3::new(0.0, 100.0, -200.0), vel: DVec3::ZERO, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false }
}
#[test]
fn shared_radar_serves_multiple_launchers_and_failure_blocks_cue() {
let mut radar = Sam::new(1, SamKind::Radar, DVec3::ZERO, 0.0);
let mut a = Sam::new(2, SamKind::S400, DVec3::new(10.0, 0.0, 0.0), 0.0);
let mut b = Sam::new(3, SamKind::S500, DVec3::new(20.0, 0.0, 0.0), 0.0);
assert!(a.set_mode(SamMode::LauncherOnly));
assert!(b.set_mode(SamMode::LauncherOnly));
assert!(!a.radar_up() && !b.radar_up());
radar.tracks.push(track(10));
radar.set_radar_role(RadarRole::Search);
radar.tracks.push(track(10));
assert!(!why::radar_cue(&a, &[&radar], 42, 10));
radar.set_radar_role(RadarRole::Height);
radar.tracks.push(track(10));
assert!(!why::radar_cue(&a, &[&radar], 42, 10));
radar.set_radar_role(RadarRole::Engagement);
radar.tracks.push(track(10));
assert!(why::radar_cue(&a, &[&radar], 42, 10));
assert!(why::radar_cue(&b, &[&radar], 42, 10));
assert!(!why::radar_cue(&a, &[&radar], 42, 10 + why::FAR_RADAR_AGE + 1));
radar.comps.0[Part::Comms as usize] = 0.0;
assert!(!why::radar_cue(&a, &[&radar], 42, 10));
radar.comps.0[Part::Comms as usize] = 1.0;
a.comps.0[Part::Comms as usize] = 0.0;
assert!(!why::radar_cue(&a, &[&radar], 42, 10));
a.comps.0[Part::Comms as usize] = 1.0;
a.pos.x = sight::LINK + 1.0;
assert!(!why::radar_cue(&a, &[&radar], 42, 10));
a.pos.x = 10.0;
radar.health = 0.0;
assert!(!why::radar_cue(&a, &[&radar], 42, 10));
}
#[test]
fn network_shares_a_measurement_to_both_launchers_without_fabricating_own_tracks() {
let mut sim = Sim::new(7);
sim.tick = 10;
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::ZERO, 0.0));
for kind in [SamKind::S400, SamKind::S500] {
let id = sim.id();
let mut launcher = Sam::new(id, kind, DVec3::new(10.0, 0.0, 0.0), 0.0);
launcher.set_mode(SamMode::LauncherOnly);
launcher.roles = 0xff;
sim.sams.push(launcher);
}
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 100.0, -200.0), DVec3::Z, DVec3::ZERO);
let id = sim.drones[0].id;
let mut measurement = track(10);
measurement.id = id;
sim.sams[0].tracks.push(measurement);
sim.update_sight(&mut Ground::default());
for launcher in &sim.sams[1..] {
let received = launcher.track(id).expect("shared measurement");
assert_eq!(received.tick, 10);
assert_eq!(received.own_tick, 0);
assert!(why::radar_cue(launcher, &[&sim.sams[0]], id, 10));
let load = launcher.kind.spec().missile.as_ref().unwrap();
let opts = why::Opts { last_chance: false, far_shots: false };
assert!(why::basis(launcher, load, received, &sim.drones[0], 10, 0, 1.0, opts, true, false).is_some());
assert!(why::basis(launcher, load, received, &sim.drones[0], 10, 0, 1.0, opts, false, false).is_none());
}
sim.sams[0].health = 0.0;
for launcher in &sim.sams[1..] {
assert!(launcher.track(id).is_some());
assert!(!why::radar_cue(launcher, &[&sim.sams[0]], id, 10));
}
}
#[test]
fn search_cues_tracking_radar_but_only_its_own_measurement_authorizes_launch() {
for in_range in [true, false] {
let mut sim = Sim::new(71);
sim.tick = 10;
let mut search = Sam::new(1, SamKind::Radar, DVec3::ZERO, 0.0);
search.set_radar_role(RadarRole::Search);
let mut fire_control = Sam::new(2, SamKind::Radar, DVec3::ZERO, 0.0);
fire_control.set_radar_role(RadarRole::Engagement);
let mut launcher = Sam::new(3, SamKind::S400, DVec3::ZERO, 0.0);
launcher.set_mode(SamMode::LauncherOnly);
launcher.roles = 0xff;
sim.sams.extend([search, fire_control, launcher]);
let distance = if in_range { 100.0 } else { 10000.0 };
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 70.0, -distance), DVec3::Z, DVec3::ZERO);
let id = sim.drones[0].id;
let mut measurement = track(10);
measurement.id = id;
measurement.pos = sim.drones[0].pos;
sim.sams[0].tracks.push(measurement);
sim.sams[0].beam = std::f64::consts::PI;
sim.sams[1].beam = std::f64::consts::PI;
let mut ground = Ground::default();
sim.update_sight(&mut ground);
assert_eq!(sim.sams[1].track(id).unwrap().own_tick, 0, "cue is not a measurement");
assert!(!why::radar_cue(&sim.sams[2], &[&sim.sams[1]], id, sim.tick));
for now in 11..40 {
sim.tick = now;
// Bearing is north (zero); force the ordinary beam away each tick.
sim.sams[1].beam = std::f64::consts::PI;
sim.update_sight(&mut ground);
if sim.sams[1].track(id).is_some_and(|t| t.own_tick > 0) { break; }
}
let got_own = sim.sams[1].track(id).is_some_and(|t| t.own_tick > 0);
assert_eq!(got_own, in_range);
assert_eq!(why::radar_cue(&sim.sams[2], &[&sim.sams[1]], id, sim.tick), in_range);
if in_range {
let tr = sim.sams[2].track(id).unwrap();
let load = sim.sams[2].kind.spec().missile.as_ref().unwrap();
let opts = why::Opts { last_chance: false, far_shots: false };
assert!(why::basis(&sim.sams[2], load, tr, &sim.drones[0], sim.tick, 0, 1.0, opts, true, false).is_some());
let mut out = Out::default();
for _ in 0..100 {
sim.step(&mut ground, None, &mut out);
if sim.sams[2].fired > 0 { break; }
}
assert!(sim.sams[2].fired > 0, "launcher never fired after its tracking radar measured the cue");
}
}
}
#[test]
fn fictional_roles_change_scan_and_height_and_coverage() {
let mut r = Sam::new(1, SamKind::Radar, DVec3::ZERO, 0.0);
let base_mast = r.sensor_mast();
let base_scan = r.scan_ticks();
let base_reach = sight::reach(&r, DroneKind::Kh101, 5000.0);
r.set_radar_role(RadarRole::Height);
assert!(r.sensor_mast() > base_mast);
r.set_radar_role(RadarRole::Engagement);
assert!(r.scan_ticks() < base_scan && r.scan_profile().agile);
assert!(sight::reach(&r, DroneKind::Kh101, 5000.0) < base_reach);
r.set_radar_role(RadarRole::Search);
assert!(sight::reach(&r, DroneKind::Kh101, 5000.0) > base_reach);
}
#[test]
fn launcher_has_no_own_map_sensor_and_mode_switch_drops_stale_tracks() {
let mut sim = Sim::new(3);
sim.sams.push(Sam::new(1, SamKind::S400, DVec3::ZERO, 0.0));
assert!(sim.low_sensor(SamKind::S400, DVec3::ZERO, Some(1)).is_some());
sim.sams[0].tracks.push(track(10));
sim.sams[0].terrain.insert(42, (10, 1));
assert!(sim.sams[0].set_mode(SamMode::LauncherOnly));
assert!(sim.low_sensor(SamKind::S400, DVec3::ZERO, Some(1)).is_none());
assert!(sim.low_sensors().is_empty());
assert!(sim.sams[0].track(42).is_none());
assert!(sim.sams[0].terrain.is_empty());
sim.sams[0].set_mode(SamMode::Integrated);
assert!(sim.low_sensor(SamKind::S400, DVec3::ZERO, Some(1)).is_some());
}
#[test]
fn configuration_clears_old_tracks_and_rejects_other_kinds() {
let mut sam = Sam::new(1, SamKind::S400, DVec3::ZERO, 0.0);
sam.tracks.push(track(10));
assert!(sam.set_mode(SamMode::LauncherOnly));
assert!(sam.tracks.is_empty());
assert!(sam.set_mode(SamMode::Integrated));
assert!(sam.radar_up());
let mut other = Sam::new(2, SamKind::Patriot, DVec3::ZERO, 0.0);
assert!(!other.set_mode(SamMode::LauncherOnly));
assert_eq!(other.mode, SamMode::Integrated);
}
}
+4
View File
@@ -66,6 +66,8 @@ pub fn bounds(id: &str) -> Option<([f64; 3], [f64; 3])> {
"airdef:patriot_trailer" => ([-9.06, 0.0, -16.0], [25.06, 16.0, 32.0]),
"airdef:patriot_trailer_burnt" => ([-9.06, 0.0, -16.0], [25.0, 16.0, 32.0]),
"airdef:patriot_truck" => ([-8.56, 0.0, -16.0], [24.56, 22.0, 32.0]),
"airdef:radar_mast" => ([4.0, 0.0, 4.0], [12.0, 32.0, 12.0]),
"airdef:radar_mast_burnt" => ([4.0, 0.0, 4.0], [12.0, 32.0, 12.0]),
"airdef:radar_panel" => ([-10.5, -6.0, 7.0], [26.5, 26.5, 12.0]),
"airdef:radar_trailer" => ([-9.0, -1.379664, -12.0], [25.0, 18.0, 26.379664]),
"airdef:radar_truck" => ([-8.56, 0.0, -16.0], [24.56, 20.0, 32.0]),
@@ -91,6 +93,8 @@ pub fn bounds(id: &str) -> Option<([f64; 3], [f64; 3])> {
"airdef:tor_chassis_burnt" => ([-13.3, 0.0, -16.0], [29.3, 15.7, 31.0]),
"airdef:tor_turret" => ([-4.0, 0.0, -11.0], [20.0, 17.5, 22.0]),
"airdef:tor_turret_burnt" => ([-4.0, 0.0, -11.0], [20.0, 17.5, 22.0]),
"airdef:tpk_cap" => ([0.0, 0.0, 7.7], [16.0, 16.0, 8.3]),
"airdef:tpk_cap_burnt" => ([0.0, 0.0, 7.7], [16.0, 16.0, 8.3]),
"airdef:tracer" => ([7.3, 7.3, -16.0], [8.7, 8.7, 32.0]),
"airdef:tzm_truck" => ([-8.56, 0.0, -16.0], [24.56, 26.0, 32.0]),
"airdef:tzm_truck_burnt" => ([-8.5, 0.0, -16.0], [24.56, 26.0, 32.0]),
+117
View File
@@ -0,0 +1,117 @@
//! Short, deliberately fictional cold-launch choreography and physical tube visuals.
//! Timing and geometry are Minecraft presentation settings, not weapon specifications.
use super::*;
pub const OPEN_TICKS: u64 = 6;
#[derive(Clone, Copy, Debug)]
pub(super) struct Prepare {
pub target: u32,
pub cell: u32,
pub start: u64,
pub ground: bool,
}
impl Sam {
pub fn animated_tubes(&self) -> bool { matches!(self.kind, SamKind::S400 | SamKind::S500) }
/// Reconcile visual occupancy with the actual shared magazine after load, repair or supply.
pub fn sync_tube_cells(&mut self) {
if !self.animated_tubes() { self.spent_cells = 0; return; }
let cells = self.kind.spec().missile.as_ref().map_or(0, |m| m.magazine);
let valid = ((1u16 << cells) - 1) as u8;
self.spent_cells &= valid;
let loaded = (self.missiles + self.ground_rounds).min(cells);
let desired = cells - loaded;
while self.spent_cells.count_ones() > desired {
let bit = self.spent_cells.trailing_zeros();
self.spent_cells &= !(1 << bit);
}
while self.spent_cells.count_ones() < desired {
let bit = ((!self.spent_cells) & valid).trailing_zeros();
self.spent_cells |= 1 << bit;
}
if self.preparing.is_none() { self.cap_open = 0.0; }
}
pub(super) fn next_tube_cell(&self) -> u32 {
let cells = self.kind.spec().missile.as_ref().map_or(1, |m| m.magazine);
(0..cells).find(|n| self.spent_cells & (1 << n) == 0).unwrap_or(0)
}
pub(super) fn cancel_prepare(&mut self) {
self.preparing = None;
self.cap_open = 0.0;
}
/// Opening reserves a particular cell; ammunition is consumed only at actual ejection.
pub(super) fn prepare_tube(&mut self, target: u32, now: u64) -> bool {
if !self.animated_tubes() { return true; }
if self.preparing.is_none_or(|p| p.target != target) {
self.preparing = Some(Prepare { target, cell: self.next_tube_cell(), start: now, ground: false });
}
let p = self.preparing.unwrap();
self.cap_cell = p.cell as u8;
self.cap_open = (now.saturating_sub(p.start) as f32 / OPEN_TICKS as f32).clamp(0.0, 1.0);
self.cap_open >= 1.0
}
pub(super) fn eject_cell(&mut self, cell: u32) {
if self.animated_tubes() { self.spent_cells |= 1 << cell; }
self.cancel_prepare();
}
}
/// A hinge is a readable game abstraction. No claim is made about the real cover mechanism.
pub(super) fn draw_caps(sam: &Sam, out: &mut Out) {
if !sam.animated_tubes() || !sam.alive() { return; }
let Muzzle::Grid(cols, rows, size) = sam.kind.spec().muzzle else { return };
let (_, basis) = sam.mount();
let side = size / 16.0 * sam.scale();
for cell in 0..cols * rows {
let open = if sam.spent_cells & (1 << cell) != 0 { 1.0 }
else if sam.cap_cell as u32 == cell { sam.cap_open } else { 0.0 };
// Grid muzzle includes TUBE_PIVOT and is exactly the front plane of the model.
let (closed, _) = sam.muzzle(cell);
let hinge = closed + basis * DVec3::new(-side * 0.5, 0.0, 0.0);
let rotation = basis * DQuat::from_rotation_y((open as f64) * 1.8);
let center = hinge + rotation * DVec3::new(side * 0.5, 0.0, 0.0);
out.model(sam.id + 8 + cell, "airdef:tpk_cap", center, rotation, side, false);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_animated_magazine_has_one_round_per_drawn_tube() {
for kind in [SamKind::S400, SamKind::S500] {
let sam = Sam::new(16, kind, DVec3::ZERO, 0.0);
let Muzzle::Grid(cols, rows, _) = kind.spec().muzzle else { panic!("tube grid") };
assert_eq!(sam.missiles, cols * rows);
let mut out = Out::default();
draw_caps(&sam, &mut out);
assert_eq!(out.models.len(), sam.missiles as usize);
for (cell, model) in out.models.iter().enumerate() {
assert!(model.at.distance(sam.muzzle(cell as u32).0) < 1e-9, "closed lid covers the real muzzle");
}
}
}
#[test]
fn opening_reserves_a_cell_and_abort_does_not_spend_rounds() {
let mut sam = Sam::new(16, SamKind::S400, DVec3::ZERO, 0.0);
assert!(!sam.prepare_tube(42, 10));
assert!(!sam.prepare_tube(42, 13));
assert_eq!(sam.cap_open, 0.5);
let cell = sam.preparing.unwrap().cell;
assert_eq!(sam.missiles, 4);
sam.cancel_prepare();
assert_eq!((sam.missiles, sam.spent_cells, sam.cap_open), (4, 0, 0.0));
assert!(!sam.prepare_tube(42, 20));
assert_eq!(sam.preparing.unwrap().cell, cell);
assert!(sam.prepare_tube(42, 20 + OPEN_TICKS));
sam.missiles -= 1;
sam.eject_cell(cell);
assert_ne!(sam.next_tube_cell(), cell);
sam.sync_tube_cells();
assert_eq!(sam.spent_cells.count_ones(), 1);
sam.missiles = 4;
sam.sync_tube_cells();
assert_eq!(sam.spent_cells, 0);
}
}
+2 -2
View File
@@ -23,7 +23,7 @@ impl Sim {
pub fn load_ground_round(&mut self, i: usize) -> Result<String, String> {
let s = self.sams.get(i).ok_or("Нет такого комплекса")?;
if s.kind != SamKind::S400 { return Err("Для этого комплекса нет игрового профиля RM48U".into()); }
if !s.alive() || s.moving() || s.reloading > 0 || s.part(Part::Launcher) <= 0.0 {
if !s.alive() || s.moving() || s.preparing.is_some() || s.reloading > 0 || s.part(Part::Launcher) <= 0.0 {
return Err("Комплекс должен быть исправен и развёрнут".into());
}
if s.ground_rounds >= s.cells() { return Err("Нет свободной ячейки для RM48U".into()); }
@@ -43,7 +43,7 @@ impl Sim {
/// Return a loaded ground round to a nearby depot; restore a previously removed air round if available.
pub fn unload_ground_round(&mut self, i: usize) -> Result<String, String> {
let s = self.sams.get(i).ok_or("Нет такого комплекса")?;
if s.kind != SamKind::S400 || !s.alive() || s.moving() || s.reloading > 0 || s.part(Part::Launcher) <= 0.0 || s.ground_rounds == 0 {
if s.kind != SamKind::S400 || !s.alive() || s.moving() || s.preparing.is_some() || s.reloading > 0 || s.part(Part::Launcher) <= 0.0 || s.ground_rounds == 0 {
return Err("Нужен исправный развёрнутый С-400 с загруженной RM48U".into());
}
let depot = self.sams.iter().enumerate().filter(|(_, d)| d.kind == SamKind::Depot && d.alive() && d.pos.distance(s.pos) < DEPOT_REACH)
+43 -5
View File
@@ -9,6 +9,8 @@
use glam::{DQuat, DVec3};
use std::collections::HashMap;
pub mod battery;
pub mod launch;
pub mod catalog;
mod generated_bounds;
mod damage;
@@ -1278,6 +1280,11 @@ pub const MAX_COAST: u64 = 240;
/// Ticks the seeker keeps a target it lost sight of (it looks again round the last place).
const REACQUIRE: u64 = 12;
impl Missile {
/// Whether the main motor is running (cold ejection has no exhaust).
pub fn engine_lit(&self) -> bool { self.age > self.kind.spec().cold }
}
impl MissileKind {
/// How far its own seeker holds a target of average size (blocks); 0: command guidance only.
pub fn seeker(self) -> f64 {
@@ -1355,6 +1362,12 @@ pub struct Sam {
launched: u32,
pub id: u32,
pub kind: SamKind,
pub mode: battery::SamMode,
pub radar_role: battery::RadarRole,
pub spent_cells: u8,
preparing: Option<launch::Prepare>,
cap_cell: u8,
cap_open: f32,
/// The vehicle's centre on the ground.
pub pos: DVec3,
/// Where its nose points (radians, as `look`'s yaw).
@@ -1441,6 +1454,12 @@ impl Sam {
Self {
id,
kind,
mode: battery::SamMode::Integrated,
radar_role: battery::RadarRole::Multifunction,
spent_cells: 0,
preparing: None,
cap_cell: 0,
cap_open: 0.0,
pos,
heading,
yaw: heading,
@@ -1544,6 +1563,9 @@ impl Sam {
SamKind::Light => return format!("{text}{}", if self.target.is_some() { ": в луче цель" } else { "" }),
_ => {}
}
if let Some(p) = self.preparing {
text += &format!(" · открытие ТПК №{}", p.cell + 1);
}
if self.empty && self.missiles == 0 {
text += " ЖДЁТ БК";
}
@@ -1829,6 +1851,8 @@ impl Sim {
}
pub fn clear(&mut self) {
self.strikes.pending.clear();
for sam in &mut self.sams { sam.cancel_prepare(); }
self.drones.clear();
self.missiles.clear();
self.fx.clear();
@@ -1889,6 +1913,7 @@ impl Sim {
self.step_ew(out);
self.update_sight(ground);
self.step_lights(ground, out);
self.prepare_ground_strikes(out);
self.step_sams(ground, out);
self.step_missiles(ground, out);
self.step_strikes(ground, player, out);
@@ -2489,6 +2514,13 @@ impl Sim {
// ------------------------------------------------ air defence
fn step_sams(&mut self, ground: &mut Ground, out: &mut Out) {
for sam in &mut self.sams {
sam.sync_tube_cells();
if (self.hold && !sam.preparing.is_some_and(|p| p.ground)) || !sam.alive() || sam.moving() || sam.reloading > 0 || sam.part(Part::Launcher) <= 0.0 {
sam.cancel_prepare();
}
}
if self.hold {
for i in 0..self.sams.len() {
if self.sams[i].alive() && !self.sams[i].moving() {
@@ -2573,10 +2605,10 @@ impl Sim {
let opts = why::Opts { last_chance: self.last_chance, far_shots: self.far_shots };
let target = spec.missile.as_ref().map(|load| {
// A launcher with nothing to fire (reloading, empty, failed) claims no target: others take it.
if me.missiles == 0 || me.reloading > 0 || me.part(Part::Launcher) <= 0.0 {
if me.missiles == 0 || me.reloading > 0 || me.part(Part::Launcher) <= 0.0 || me.preparing.is_some_and(|p| p.ground) {
return None;
}
let radars: Vec<&Sam> = if me.kind.far_shooter() && opts.far_shots { self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect() } else { Vec::new() };
let radars: Vec<&Sam> = if me.mode == battery::SamMode::LauncherOnly || (me.kind.far_shooter() && opts.far_shots) { self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect() } else { Vec::new() };
let cued = |id: u32| !radars.is_empty() && why::radar_cue(me, &radars, id, now);
let basis = |tr: &Track, k: f64, role_in_reach: bool| {
drone(tr).filter(|_| open(tr.id)).and_then(|d| why::basis(me, load, tr, d, now, claimed(d), k, opts, cued(tr.id), role_in_reach))
@@ -2641,6 +2673,10 @@ impl Sim {
self.sams[i].gun_target = gun_target;
}
let sam = &mut self.sams[i];
if sam.preparing.is_some_and(|p| !p.ground && Some(p.target) != sam.target) { sam.cancel_prepare(); }
if let Some(p) = sam.preparing.filter(|p| !p.ground) {
sam.cap_open = (now.saturating_sub(p.start) as f32 / launch::OPEN_TICKS as f32).clamp(0.0, 1.0);
}
// What the turret or launcher points at: guns first when a target is close.
let aim = sam
.gun_target
@@ -2783,11 +2819,11 @@ impl Sim {
}
// Missiles.
if let (Some(load), Some(mt)) = (&spec.missile, sam.target) {
let ready = why::missile_blocks(sam) == 0;
let ready = why::missile_blocks(sam) == 0 && !sam.preparing.is_some_and(|p| p.ground);
// The missile turns after its boost, but the launcher must point near the target.
let t = sam.track(mt).filter(|_| drone_at.contains_key(&mt)).map(|tr| tr.est(now));
let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim_id, mt, aim_error);
let n = load.magazine - sam.missiles;
let n = if sam.animated_tubes() { sam.preparing.map_or_else(|| sam.next_tube_cell(), |p| p.cell) } else { load.magazine - sam.missiles };
let (muzzle, dir) = sam.muzzle(n);
// The land: a target behind a hill, or a path the hill closes, is not fired at; the system looks for another.
let land = if ready && aligned {
@@ -2816,9 +2852,11 @@ impl Sim {
if land == terrain::WAIT {
// The land under the shot is not known yet: it waits.
} else if land != 0 {
sam.cancel_prepare();
retarget(&mut missile_on, Some(mt), None);
sam.target = None;
} else if ready && aligned {
} else if ready && aligned && sam.prepare_tube(mt, now) {
sam.eject_cell(n);
// Past its normal reach: a long shot, and the next waits.
if sam.kind.far_shooter() && t.is_some_and(|t| t.distance(sam.pos) >= load.max) {
sam.far_cooldown = why::FAR_COOLDOWN;
+45 -2
View File
@@ -23,6 +23,11 @@ pub struct SamKey {
/// Hull and component health at the sampled tick.
health: f32,
parts: [f32; 6],
mode: battery::SamMode,
radar_role: battery::RadarRole,
spent_cells: u8,
cap_cell: u8,
cap_open: f32,
}
impl SamKey {
@@ -38,6 +43,11 @@ impl SamKey {
beam: if sam.beam_on { ((sam.beam_free / 8.0).round() * 8.0).max(8.0) as f32 } else { 0.0 },
health: sam.health as f32,
parts: sam.comps.0.map(|v| v as f32),
mode: sam.mode,
radar_role: sam.radar_role,
spent_cells: sam.spent_cells,
cap_cell: sam.cap_cell,
cap_open: sam.cap_open,
}
}
/// The same pose (to within what could be seen).
@@ -51,6 +61,9 @@ impl SamKey {
&& self.beam == o.beam
&& self.health == o.health
&& self.parts == o.parts
&& self.mode == o.mode && self.radar_role == o.radar_role
&& self.spent_cells == o.spent_cells && self.cap_cell == o.cap_cell
&& near(self.cap_open, o.cap_open, 0.001)
}
}
@@ -93,6 +106,11 @@ impl SamTrack {
beam: a.beam,
health: a.health,
parts: a.parts,
mode: a.mode,
radar_role: a.radar_role,
spent_cells: a.spent_cells,
cap_cell: a.cap_cell,
cap_open: if a.cap_cell == b.cap_cell && a.spent_cells == b.spent_cells { l(a.cap_open, b.cap_open) } else { a.cap_open },
})
}
/// The system as it stood at `t`, as a ghost to draw with the ids from `id`.
@@ -107,6 +125,11 @@ impl SamTrack {
sam.beam_free = k.beam as f64;
sam.health = if self.lost.is_some_and(|l| t >= l as f64) { 0.0 } else { k.health as f64 };
sam.comps = Comps(k.parts.map(|v| v as f64));
sam.mode = k.mode;
sam.radar_role = k.radar_role;
sam.spent_cells = k.spent_cells;
sam.cap_cell = k.cap_cell;
sam.cap_open = k.cap_open;
Some(sam)
}
}
@@ -757,7 +780,7 @@ impl Sim {
/// Open before the combat update so transitions on the first active tick are not lost.
pub(super) fn begin_recording_if_active(&mut self) {
if self.rec.is_none() && (self.drones.iter().any(|d| d.alive()) || !self.strikes.flights.is_empty()) {
if self.rec.is_none() && (self.drones.iter().any(|d| d.alive()) || !self.strikes.flights.is_empty() || !self.strikes.pending.is_empty()) {
self.recordings += 1;
self.rec = Some(Recording::new(self.recordings, self));
}
@@ -775,7 +798,7 @@ impl Sim {
pub(super) fn record(&mut self) {
// Wrecks still falling (far off they fall a long while) don't keep it going. A counter-strike is a recorded
// object in its own right (#109): it holds the recording open until it lands, whatever the sky is doing.
let busy = self.drones.iter().any(|d| d.alive()) || !self.missiles.is_empty() || !self.queue.is_empty() || !self.strikes.flights.is_empty();
let busy = self.drones.iter().any(|d| d.alive()) || !self.missiles.is_empty() || !self.queue.is_empty() || !self.strikes.flights.is_empty() || !self.strikes.pending.is_empty();
self.begin_recording_if_active();
let Some(r) = &mut self.rec else { return };
r.note_tracks(&self.sams);
@@ -1006,6 +1029,26 @@ mod tests {
}
}
#[test]
fn replay_preserves_launcher_configuration_and_individual_cover_poses() {
let mut r = recording();
let mut sam = sam_at(0.0, 0.0, 0.0);
sam.set_mode(battery::SamMode::LauncherOnly);
sam.cap_cell = 2;
sam.cap_open = 0.5;
sam.spent_cells = 1;
r.note_sams(&[sam], 10);
for t in [10.0, 40.0, 10.0] {
let ghost = r.sam_tracks[0].ghost(r.origin, t, 160).unwrap();
assert_eq!(ghost.mode, battery::SamMode::LauncherOnly);
assert_eq!((ghost.spent_cells, ghost.cap_cell, ghost.cap_open), (1, 2, 0.5));
let mut out = Out::default();
units::draw_sam_model(&ghost, &mut Rng::new(1), &mut out);
assert_eq!(out.models.iter().filter(|m| m.name == "airdef:tpk_cap").count(), 4);
assert!(!out.models.iter().any(|m| m.name == "airdef:radar_panel"));
}
}
fn physical_strike(record_len: Option<u32>) -> (Sim, u32, DVec3) {
use crate::sim::sites::{LaunchSite, SiteClass};
use crate::sim::strike::{Estimate, Known, SiteState};
+54 -20
View File
@@ -36,6 +36,8 @@ const NEAR: f64 = 24.0;
pub(super) struct Horizon {
/// Per ray, per sample out: the steepest rise of the land so far (tan of its elevation).
pub(super) rays: Vec<Vec<f32>>,
antenna: DVec3,
reach: f64,
}
impl Horizon {
@@ -58,9 +60,12 @@ impl Horizon {
.collect()
})
.collect();
Self { rays }
Self { rays, antenna, reach }
}
fn matches(&self, sam: &Sam) -> bool {
self.antenna == antenna(sam) && self.reach == sensor_span(sam)
}
/// Whether the land between the antenna and `p` rises over the line to it.
pub(super) fn hides(&self, antenna: DVec3, p: DVec3) -> bool {
let rel = p - antenna;
@@ -78,7 +83,11 @@ impl Horizon {
/// Where a unit's radar antenna is.
pub(super) fn antenna(sam: &Sam) -> DVec3 {
sam.pos + DVec3::Y * sam.kind.spec().mast
sam.pos + DVec3::Y * sam.sensor_mast()
}
fn sensor_span(sam: &Sam) -> f64 {
sam.kind.spec().sensor * if sam.kind == SamKind::Radar { sam.radar_role.reach_factor() } else { 1.0 }
}
/// How far a system's search radar reaches for this kind at `height` over the land:
@@ -88,8 +97,8 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 {
if spec.sensor <= 0.0 {
return 0.0;
}
let radar = spec.sensor * sam.radar_fit() * kind.rcs().powf(0.25);
let horizon = HORIZON * (spec.mast.sqrt() + height.max(0.0).sqrt());
let radar = spec.sensor * sam.radar_fit() * if sam.kind == SamKind::Radar { sam.radar_role.reach_factor() } else { 1.0 } * kind.rcs().powf(0.25);
let horizon = HORIZON * (sam.sensor_mast().sqrt() + height.max(0.0).sqrt());
radar.min(horizon).max(NEAR)
}
@@ -127,15 +136,17 @@ impl Sim {
/// A radar for a unit of `kind` at `pos` as the map draws it; `id`: the unit it is (its parts and horizon), None for a ghost.
pub fn low_sensor(&self, kind: SamKind, pos: DVec3, id: Option<u32>) -> Option<LowSensor> {
let spec = kind.spec();
if spec.sensor <= 0.0 {
if spec.sensor <= 0.0 || id.and_then(|i| self.sams.iter().find(|s| s.id == i)).is_some_and(|s| s.mode == battery::SamMode::LauncherOnly) {
return None;
}
let fit = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).map_or(1.0, |s| s.radar_fit());
let height = LOW_KIND.spec().clearance;
let radar = spec.sensor * fit * LOW_KIND.rcs().powf(0.25);
let reach = radar.min(HORIZON * (spec.mast.sqrt() + height.sqrt())).max(NEAR);
let terrain = id.is_some_and(|i| self.horizons.contains_key(&i));
Some(LowSensor { id: id.unwrap_or(u32::MAX), ant: pos + DVec3::Y * spec.mast, base: pos.y, reach, terrain })
let mast = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).map_or(spec.mast, |s| s.sensor_mast());
let factor = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).filter(|s| s.kind == SamKind::Radar).map_or(1.0, |s| s.radar_role.reach_factor());
let radar = spec.sensor * fit * factor * LOW_KIND.rcs().powf(0.25);
let reach = radar.min(HORIZON * (mast.sqrt() + height.sqrt())).max(NEAR);
let terrain = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).is_some_and(|s| self.horizons.get(&s.id).is_some_and(|h| h.matches(s)));
Some(LowSensor { id: id.unwrap_or(u32::MAX), ant: pos + DVec3::Y * mast, base: pos.y, reach, terrain })
}
/// The radars that are up now.
@@ -297,13 +308,26 @@ impl Track {
}
impl Sam {
/// Fictional antenna height and scan characteristics for gameplay roles.
pub(super) fn sensor_mast(&self) -> f64 {
self.kind.spec().mast + if self.kind == SamKind::Radar && self.radar_role == battery::RadarRole::Height { 8.0 } else { 0.0 }
}
pub(super) fn scan_profile(&self) -> Scan {
if self.kind != SamKind::Radar { return self.kind.scan(); }
match self.radar_role {
battery::RadarRole::Multifunction => self.kind.scan(),
battery::RadarRole::Search => Scan { period: 170, agile: false },
battery::RadarRole::Engagement => Scan { period: 70, agile: true },
battery::RadarRole::Height => Scan { period: 140, agile: false },
}
}
/// Ticks for its beam to go round (longer when the radar is damaged).
pub(super) fn scan_ticks(&self) -> f64 {
self.kind.scan().period as f64 / self.radar_fit().max(0.2)
self.scan_profile().period as f64 / self.radar_fit().max(0.2)
}
/// Its own radar works: it has one, it is not failed and the unit is deployed.
pub fn radar_up(&self) -> bool {
self.alive() && !self.moving() && self.kind.spec().sensor > 0.0 && self.part(Part::Sensor) > 0.0
self.mode != battery::SamMode::LauncherOnly && self.alive() && !self.moving() && self.kind.spec().sensor > 0.0 && self.part(Part::Sensor) > 0.0
}
pub fn track(&self, id: u32) -> Option<&Track> {
self.tracks.iter().find(|t| t.id == id)
@@ -339,18 +363,24 @@ impl Sim {
/// into the tracks, old tracks out, and the network's tracks shared.
pub(super) fn update_sight(&mut self, ground: &mut Ground) {
let now = self.tick;
// A role, mast or location change invalidates the cached terrain immediately.
for sam in &self.sams {
if sam.radar_up() && self.horizons.get(&sam.id).is_some_and(|h| !h.matches(sam)) {
self.horizons.insert(sam.id, Horizon::build(antenna(sam), sensor_span(sam), ground, now));
}
}
// One horizon every few ticks: the land answers come in over time.
if !self.sams.is_empty() && now % 3 == 0 {
let radars: Vec<usize> =
(0..self.sams.len()).filter(|&i| self.sams[i].alive() && self.sams[i].kind.spec().sensor > 0.0).collect();
(0..self.sams.len()).filter(|&i| self.sams[i].radar_up()).collect();
if !radars.is_empty() {
let i = radars[(now / 3) as usize % radars.len()];
let sam = &self.sams[i];
let h = Horizon::build(antenna(sam), sam.kind.spec().sensor, ground, now);
let h = Horizon::build(antenna(sam), sensor_span(sam), ground, now);
self.horizons.insert(sam.id, h);
}
}
let alive: std::collections::HashSet<u32> = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect();
let alive: std::collections::HashSet<u32> = self.sams.iter().filter(|s| s.radar_up()).map(|s| s.id).collect();
self.horizons.retain(|id, _| alive.contains(id));
// Each drone's height over the land under it (or over the systems' ground, unknown).
let heights: Vec<Option<f64>> =
@@ -371,7 +401,7 @@ impl Sim {
index.clear();
index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k)));
let spec = sam.kind.spec();
let scan = sam.kind.scan();
let scan = sam.scan_profile();
let period = sam.scan_ticks();
let w = TAU / period;
let (b0, ttl) = (sam.beam, ((period * 3.0) as u32).max(60));
@@ -385,7 +415,7 @@ impl Sim {
}
let rel = d.pos - at;
let dist = rel.length();
if dist > spec.sensor * 1.2 {
if dist > spec.sensor * if sam.kind == SamKind::Radar { sam.radar_role.reach_factor().max(1.0) * 1.2 } else { 1.2 } {
continue;
}
let locked = sam.target == Some(d.id) || sam.gun_target == Some(d.id);
@@ -393,7 +423,11 @@ impl Sim {
// The beam passes its bearing this tick (the angle wraps at 2π: the difference is taken round).
let in_beam = swept(rel.x.atan2(-rel.z), b0, w);
let revisit = scan.agile && held.is_some_and(|t| t.own_tick > 0 && now - t.own_tick >= (period / 3.0) as u64);
if !(in_beam || locked || revisit) {
// A search radar's network track cues this fictional tracking radar to look, but is
// never itself a measurement: reach, terrain, probability and noise still apply.
let cue = sam.kind == SamKind::Radar && sam.radar_role == battery::RadarRole::Engagement
&& held.is_some_and(|t| t.own_tick == 0 && t.age(now) <= MISSILE_AGE);
if !(in_beam || locked || revisit || cue) {
continue;
}
let height = h.unwrap_or(d.pos.y - sam.pos.y);
@@ -412,7 +446,7 @@ impl Sim {
if dist > NEAR && dice(seed, now, key, 0) >= p {
continue;
}
let sigma = if locked { 0.3 } else { 0.8 + dist * 0.004 };
let sigma = if locked { 0.3 } else if sam.kind == SamKind::Radar && sam.radar_role == battery::RadarRole::Engagement { 0.45 + dist * 0.002 } else { 0.8 + dist * 0.004 };
let noise = DVec3::new(dice(seed, now, key, 1) - 0.5, (dice(seed, now, key, 2) - 0.5) * 0.5, dice(seed, now, key, 3) - 0.5) * 2.0 * sigma;
fresh.push(Track {
id: d.id,
@@ -435,12 +469,12 @@ impl Sim {
let shared: Vec<(u32, DVec3, f64, Vec<Track>)> = self
.sams
.iter()
.filter(|s| s.alive() && !s.moving() && s.link_range() > 0.0)
.filter(|s| s.radar_up() && s.link_range() > 0.0)
.map(|s| (s.id, s.pos, s.link_range(), s.tracks.iter().filter(|t| t.own_tick == t.tick).copied().collect::<Vec<_>>()))
.filter(|s| !s.3.is_empty())
.collect();
for sam in &mut self.sams {
if sam.alive() && !sam.moving() && sam.kind.armed() && sam.link_range() > 0.0 && !shared.is_empty() {
if sam.alive() && !sam.moving() && (sam.kind.armed() || (sam.kind == SamKind::Radar && sam.radar_role == battery::RadarRole::Engagement)) && sam.link_range() > 0.0 && !shared.is_empty() {
index.clear();
index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k)));
let mine = sam.link_range();
+124 -27
View File
@@ -308,6 +308,13 @@ impl Flight {
}
}
#[derive(Clone, Copy, Debug)]
pub(super) struct PendingStrike {
carrier: u32,
site: u32,
aim: DVec2,
}
#[derive(Default)]
pub struct Strikes {
/// Strike missiles left, and ticks towards the next.
@@ -315,6 +322,7 @@ pub struct Strikes {
pub progress: u32,
pub known: Vec<Known>,
pub flights: Vec<Flight>,
pub(super) pending: Vec<PendingStrike>,
flown: Vec<Flown>,
next: u32,
/// Smoking wrecks: (where, until).
@@ -581,7 +589,10 @@ impl Sim {
let from = f.pos;
// Time spent waiting for world data must not accumulate into a faster step.
let delta = planned - from;
let want = from + delta.clamp_length_max(TUNE.speed);
let speed = if f.carrier.is_some() && age >= EJECT_TICKS {
TUNE.speed * (0.2 + 0.8 * ((age - EJECT_TICKS) as f64 / 20.0).clamp(0.0, 1.0))
} else { TUNE.speed };
let want = from + delta.clamp_length_max(speed);
// A missile that never reaches its point (the world never tells of the way) ends somewhere: it does
// not stay in the air for ever.
if now >= f.arrive + 600 {
@@ -671,6 +682,7 @@ impl Sim {
/// while these exist, and let go after.
pub(super) fn site_refs(&mut self) -> Vec<u32> {
let mut v: Vec<u32> = self.strikes.flights.iter().map(|f| f.site).collect();
v.extend(self.strikes.pending.iter().map(|p| p.site));
let alive: Vec<u32> = self.drones.iter().filter(|d| d.alive()).map(|d| d.id).collect();
v.extend(self.strikes.scouted(&alive));
v.extend(self.sites.flying_sites(&alive));
@@ -679,42 +691,77 @@ impl Sim {
v
}
/// Atomic manual launch from an already prepared, compatible carrier.
pub fn order_strike(&mut self, id: u32, _from: DVec3, out: &mut Out) -> Result<String, String> {
/// Reserve a physical carrier and its cell; ammunition is spent only on ejection.
pub fn order_strike(&mut self, id: u32, _from: DVec3, _out: &mut Out) -> Result<String, String> {
let k = self.strikes.get(id).ok_or_else(|| format!("ПУ №{id} не найдена"))?;
let est = k.est.filter(|e| e.fix).ok_or("Место ПУ неизвестно: нужна разведка")?;
if matches!(k.state, SiteState::Destroyed(_)) { return Err("ПУ уже уничтожена".into()); }
if self.strikes.flights.iter().any(|f| f.site == id) { return Err("По этой ПУ ракета уже в полёте".into()); }
let site = self.sites.get(id).ok_or("ПУ уже нет на карте")?;
let snap = Target { pos: DVec2::new(site.pos.x, site.pos.z), stock: site.stock, offmap: site.offmap, alive: site.alive };
let (i, dist) = self.sams.iter().enumerate().filter_map(|(i,s)| {
if self.strikes.flights.iter().any(|f| f.site == id) || self.strikes.pending.iter().any(|p| p.site == id) {
return Err("По этой ПУ ракета уже готовится или в полёте".into());
}
if !self.sites.get(id).is_some_and(|s| s.alive) { return Err("ПУ уже нет на карте".into()); }
let (i, _) = self.sams.iter().enumerate().filter_map(|(i,s)| {
let dist = (est.pos - DVec2::new(s.pos.x, s.pos.z)).length();
(matches!(catalog::unit(s.kind).ground_strike(), catalog::GroundStrike::Rm48uGame)
&& s.alive() && !s.moving() && s.reloading == 0 && s.cooldown == 0
&& s.part(Part::Launcher) > 0.0 && s.ground_rounds > 0 && dist <= TUNE.range)
&& s.part(Part::Launcher) > 0.0 && s.ground_rounds > 0 && s.preparing.is_none() && dist <= TUNE.range)
.then_some((i, dist))
}).min_by(|(i,a), (j,b)| self.sams[*j].ground_rounds.cmp(&self.sams[*i].ground_rounds)
.then(a.total_cmp(b)).then(self.sams[*i].id.cmp(&self.sams[*j].id)))
.ok_or("Нет готового С-400 с загруженной RM48U в игровой дальности; подготовьте боезапас у склада")?;
let sam = &mut self.sams[i];
let carrier = sam.id;
let (start, _) = sam.muzzle(4 - sam.ground_rounds);
sam.ground_rounds -= 1;
sam.cooldown = 20;
sam.launched = 12;
sam.fired += 1;
self.intel.revealed.push((carrier, sam.pos, self.tick));
self.strikes.fired += 1;
self.strikes.next += 1;
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64;
let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, id as u64, 7), gauss(self.seed, self.strikes.next as u64, id as u64, 8));
self.strikes.flights.push(Flight { id: self.strikes.next, site: id, carrier: Some(carrier), from: start, pos: start, aim: est.pos,
land: est.pos + DVec2::new(gx, gz) * TUNE.cep, start: self.tick, arrive: self.tick + ticks, snap, hold: 0 });
if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == id) { k.aimed = true; }
self.record_strike_launch(self.strikes.next, carrier, start);
out.sound("airdef:launch.s400_eject", start, 1.0, 1.0);
out.particles("minecraft:cloud", start, 0.6, DVec3::Y * 0.05, 0.05, 30);
Ok(format!("С-400 №{} запустил RM48U по ПУ №{id}; осталось ударных: {}", i + 1, self.sams[i].ground_rounds))
sam.sync_tube_cells();
let cell = sam.next_tube_cell();
sam.preparing = Some(launch::Prepare { target: id, cell, start: self.tick, ground: true });
sam.cap_cell = cell as u8;
sam.cap_open = 0.0;
self.strikes.pending.push(PendingStrike { carrier: sam.id, site: id, aim: est.pos });
Ok(format!("С-400 №{} готовит RM48U по ПУ №{id}", i + 1))
}
pub(super) fn prepare_ground_strikes(&mut self, out: &mut Out) {
let pending = std::mem::take(&mut self.strikes.pending);
for p in pending {
let Some(i) = self.sams.iter().position(|s| s.id == p.carrier) else { continue };
let valid_site = self.sites.get(p.site).is_some_and(|s| s.alive)
&& self.strikes.get(p.site).is_some_and(|k| !matches!(k.state, SiteState::Destroyed(_)));
let sam = &self.sams[i];
let valid = valid_site && sam.preparing.is_some_and(|q| q.ground && q.target == p.site)
&& sam.alive() && !sam.moving() && sam.reloading == 0 && sam.cooldown == 0
&& sam.part(Part::Launcher) > 0.0 && sam.ground_rounds > 0;
if !valid {
out.messages.push(format!("Подготовка RM48U по ПУ №{} отменена: изменилось состояние носителя или цели", p.site));
if self.sams[i].preparing.is_some_and(|q| q.ground && q.target == p.site) { self.sams[i].cancel_prepare(); }
continue;
}
let prep = self.sams[i].preparing.unwrap();
self.sams[i].cap_open = (self.tick.saturating_sub(prep.start) as f32 / launch::OPEN_TICKS as f32).clamp(0.0, 1.0);
if self.tick.saturating_sub(prep.start) < launch::OPEN_TICKS {
self.strikes.pending.push(p);
continue;
}
let site = self.sites.get(p.site).unwrap();
let snap = Target { pos: DVec2::new(site.pos.x, site.pos.z), stock: site.stock, offmap: site.offmap, alive: site.alive };
let sam = &mut self.sams[i];
let (start, _) = sam.muzzle(prep.cell);
sam.eject_cell(prep.cell);
sam.ground_rounds -= 1;
sam.cooldown = 20;
sam.launched = 12;
sam.fired += 1;
let dist = (p.aim - DVec2::new(sam.pos.x, sam.pos.z)).length();
self.intel.revealed.push((p.carrier, sam.pos, self.tick));
self.strikes.fired += 1;
self.strikes.next += 1;
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64;
let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, p.site as u64, 7), gauss(self.seed, self.strikes.next as u64, p.site as u64, 8));
self.strikes.flights.push(Flight { id: self.strikes.next, site: p.site, carrier: Some(p.carrier), from: start, pos: start, aim: p.aim,
land: p.aim + DVec2::new(gx, gz) * TUNE.cep, start: self.tick, arrive: self.tick + ticks, snap, hold: 0 });
if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == p.site) { k.aimed = true; }
self.record_strike_launch(self.strikes.next, p.carrier, start);
out.sound("airdef:launch.s400_eject", start, 1.0, 1.0);
out.particles("minecraft:cloud", start, 0.6, DVec3::Y * 0.05, 0.05, 30);
}
}
/// Synthetic launcher used only by flight/impact regression fixtures; absent from production builds.
@@ -1243,9 +1290,13 @@ mod tests {
sim.load_ground_round(a).unwrap();
sim.sams[a].cooldown = 0;
sim.sams[b].cooldown = 0;
let muzzle = sim.sams[b].muzzle(2).0;
let cell = sim.sams[b].next_tube_cell();
let muzzle = sim.sams[b].muzzle(cell).0;
let old_stock = sim.strikes.stock;
sim.order_strike(site, DVec3::new(99999.0, 0.0, 99999.0), &mut out).unwrap();
assert_eq!((sim.sams[a].ground_rounds, sim.sams[b].ground_rounds, sim.strikes.stock), (1, 2, old_stock));
assert!(sim.order_strike(site, DVec3::ZERO, &mut out).is_err());
run(&mut sim, &mut ground, &mut out, launch::OPEN_TICKS as u32 + 1);
assert_eq!((sim.sams[a].ground_rounds, sim.sams[b].ground_rounds, sim.strikes.stock), (1, 1, old_stock));
let f = sim.strikes.flights.last().unwrap();
assert_eq!((f.carrier, f.from), (Some(b_id), muzzle));
@@ -1256,6 +1307,52 @@ mod tests {
assert!(sim.sams[b].missiles + sim.sams[b].ground_rounds <= 4);
}
#[test]
fn ground_preparation_cancels_without_spending_when_carrier_or_site_changes() {
for reason in 0..6 {
let mut sim = Sim::new(11);
let site = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 300.0), 0);
let mut sam = Sam::new(123, SamKind::S400, DVec3::ZERO, 0.0);
sam.missiles = 2; sam.ground_rounds = 2; sam.cooldown = 0;
sim.sams.push(sam);
let mut out = Out::default();
sim.order_strike(site, DVec3::ZERO, &mut out).unwrap();
match reason {
0 => sim.sams[0].health = 0.0,
1 => sim.sams[0].state = SamState::Moving,
2 => sim.sams[0].reloading = 5,
3 => sim.sams[0].cooldown = 5,
4 => sim.sams[0].ground_rounds = 0,
_ => sim.sites.list.iter_mut().find(|s| s.id == site).unwrap().alive = false,
}
sim.tick += launch::OPEN_TICKS;
sim.prepare_ground_strikes(&mut out);
assert!(sim.strikes.pending.is_empty() && sim.strikes.flights.is_empty());
assert_eq!(sim.strikes.fired, 0);
assert_eq!(sim.sams[0].ground_rounds, if reason == 4 { 0 } else { 2 });
assert!(sim.sams[0].preparing.is_none());
}
}
#[test]
fn ground_orders_reserve_the_carrier_and_target_until_ejection() {
let mut sim = Sim::new(11);
let a = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 300.0), 0);
let b = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 400.0), 0);
let mut sam = Sam::new(123, SamKind::S400, DVec3::ZERO, 0.0);
sam.missiles = 2; sam.ground_rounds = 2; sam.cooldown = 0;
sim.sams.push(sam);
let mut out = Out::default();
sim.order_strike(a, DVec3::ZERO, &mut out).unwrap();
assert!(sim.order_strike(a, DVec3::ZERO, &mut out).is_err());
assert!(sim.order_strike(b, DVec3::ZERO, &mut out).is_err());
assert_eq!(sim.sams[0].ground_rounds, 2);
sim.tick += launch::OPEN_TICKS;
sim.prepare_ground_strikes(&mut out);
assert_eq!((sim.sams[0].ground_rounds, sim.strikes.fired, sim.strikes.flights.len()), (1, 1, 1));
assert!(sim.strikes.pending.is_empty());
}
#[test]
fn fully_loaded_ground_cells_do_not_consume_air_reload_cargo() {
let mut sim = Sim::new(1);
+14 -3
View File
@@ -279,7 +279,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "С-500",
key: "s500",
missile: Some(MissileLoad { kind: MissileKind::M77n6, magazine: 4, min: 40.0, max: 1050.0, interval: 20 }),
missile: Some(MissileLoad { kind: MissileKind::M77n6, magazine: 2, min: 40.0, max: 1050.0, interval: 20 }),
gun: None,
reload: 520,
scale: 1.6,
@@ -681,6 +681,7 @@ pub(super) fn draw_sam_model(sam: &Sam, rng: &mut Rng, out: &mut Out) {
}
Family::Static | Family::Truck => {}
}
if sam.mode != battery::SamMode::LauncherOnly {
if let Some((model, h, f, tilt)) = parts.dish {
part(out, model, mount + turret * DVec3::new(0.0, h * s, f * s), DQuat::from_rotation_y(sam.dish) * DQuat::from_rotation_x(tilt), s);
}
@@ -688,9 +689,19 @@ pub(super) fn draw_sam_model(sam: &Sam, rng: &mut Rng, out: &mut Out) {
part(out, model, sam.pos + up + DVec3::Y * h * s + base * DVec3::new(0.0, 0.0, back * s), base, s);
}
if let Some((model, h, back, size, tilt)) = parts.spinner {
let at = sam.pos + up + DVec3::Y * h * s + base * DVec3::new(0.0, 0.0, back * s);
part(out, model, at, DQuat::from_rotation_y(sam.dish * 0.6) * DQuat::from_rotation_x(tilt), s * size);
let extra = if sam.kind == SamKind::Radar && sam.radar_role == battery::RadarRole::Height { 8.0 } else { 0.0 };
let foot = sam.pos + up + DVec3::Y * h * s + base * DVec3::new(0.0, 0.0, back * s);
for segment in 0..(extra as u32 / 2) {
part(out, "airdef:radar_mast", foot + DVec3::Y * (segment as f64 * 2.0 + 0.5), base, 1.0);
}
let at = foot + DVec3::Y * extra;
let heading = if sam.kind == SamKind::Radar && sam.radar_role == battery::RadarRole::Engagement {
sam.tracks.first().map_or(sam.dish * 0.6, |t| heading_of(t.pos - sam.pos))
} else { sam.dish * 0.6 };
part(out, model, at, DQuat::from_rotation_y(heading) * DQuat::from_rotation_x(tilt), s * size);
}
}
launch::draw_caps(sam, out);
if sam.flash > 0 {
let barrels: &[DVec3] = match sam.kind {
SamKind::Pantsir => &[DVec3::new(-0.95, 0.33, -1.7), DVec3::new(0.95, 0.33, -1.7)],
+68 -7
View File
@@ -92,11 +92,14 @@ impl SamKind {
/// A live big radar in data-link reach of `sam` that measured `id` recently.
pub(super) fn radar_cue(sam: &Sam, radars: &[&Sam], id: u32, now: u64) -> bool {
radars.iter().any(|r| {
r.alive()
r.radar_role.authorizes()
&& r.link_range() > 0.0
&& sam.link_range() > 0.0
&& r.alive()
&& !r.moving()
&& r.radar_up()
&& r.pos.distance(sam.pos) <= r.link_range().min(sam.link_range())
&& r.track(id).is_some_and(|t| t.own_tick > 0 && t.age(now) <= FAR_RADAR_AGE)
&& r.track(id).is_some_and(|t| t.own_tick > 0 && now.saturating_sub(t.own_tick) <= if sam.mode == battery::SamMode::LauncherOnly { sight::MISSILE_AGE } else { FAR_RADAR_AGE })
})
}
@@ -134,6 +137,7 @@ pub(super) struct Opts {
/// `role_in_reach` that the system has a target of its roles in range anyway (no long shots then).
#[allow(clippy::too_many_arguments)]
pub(super) fn basis(sam: &Sam, load: &units::MissileLoad, track: &Track, d: &Drone, now: u64, claimed: u32, k: f64, opts: Opts, cued: bool, role_in_reach: bool) -> Option<Basis> {
if sam.mode == battery::SamMode::LauncherOnly && !cued { return None; }
let blocks = target_blocks(sam.roles, Some(track), now, d);
if blocks & !ROLE != 0 {
return None;
@@ -390,12 +394,16 @@ impl Sim {
_ => "не обнаружена ни своей РЛС, ни сетью".to_string(),
};
v.push(check(blocks & (UNSEEN | STALE) == 0, detect));
if sam.kind.spec().sensor > 0.0 {
if sam.mode == battery::SamMode::LauncherOnly {
let radars: Vec<&Sam> = self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect();
let connected = radar_cue(sam, &radars, t.id, now);
v.push(check(connected, if connected { "внешняя РЛС: свежий трек по связи" } else { "внешняя РЛС: нет свежего трека или связи" }.to_string()));
} else if sam.kind.spec().sensor > 0.0 {
let period = sam.scan_ticks();
v.push(check(
sam.radar_up(),
if sam.radar_up() {
format!("обзор: оборот {:.1} с{}, луч на {:.0}°", period / 20.0, if sam.kind.scan().agile { ", электронный, цели в треке чаще" } else { "" }, sam.beam.to_degrees())
format!("обзор: оборот {:.1} с{}, луч на {:.0}°", period / 20.0, if sam.scan_profile().agile { ", электронный, цели в треке чаще" } else { "" }, sam.beam.to_degrees())
} else {
"обзор: РЛС не работает".to_string()
},
@@ -427,7 +435,7 @@ impl Sim {
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, est);
// Beyond the normal reach, a heavy system may fire on a big radar's cue.
let radars: Vec<&Sam> = self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect();
let cued = sam.kind.far_shooter() && radar_cue(sam, &radars, t.id, now);
let cued = radar_cue(sam, &radars, t.id, now);
let role_in_reach = self.drones.iter().any(|o| {
sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && sam.doctrine.area_blocks(sam.pos, tr, now) | sam.doctrine.reserve_blocks(sam.missiles, o) == 0 && claimed(o, &missile_on, sam.target) < sam.doctrine.needed(o))
});
@@ -483,7 +491,7 @@ impl Sim {
));
}
let doc_ok = chance || area | held_back == 0;
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && doc_ok && (reach == 0 || far_ok);
let grounds = (sam.mode != battery::SamMode::LauncherOnly || cued) && blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && doc_ok && (reach == 0 || far_ok);
fires |= grounds && free < need && mb == 0 && aligned && land == 0;
}
if let Some(gun) = &spec.gun {
@@ -516,6 +524,11 @@ impl Sim {
v.push(check(gb == 0, if gb == 0 { "орудие готово".to_string() } else { format!("орудие не стреляет: {}", why.join(", ")) }));
fires |= gb == 0 && cand;
}
if let Some(p) = sam.preparing {
let opening = p.ground || now.saturating_sub(p.start) < launch::OPEN_TICKS;
v.push(check(!opening, format!("подготовка пуска: открывается крышка ТПК №{}", p.cell + 1)));
fires &= !opening;
}
v.push(check(fires, if fires { "итог: готова стрелять" } else { "итог: стрелять сейчас не может" }));
v
}
@@ -686,7 +699,8 @@ mod tests {
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
let far = sim.drones[0].id;
sim.add_drone(DroneKind::Kh101, DVec3::new(300.0, 120.0, 0.0), DVec3::NEG_X, DVec3::new(0.0, 70.0, 0.0));
for _ in 0..40 {
// The launcher must finish opening its TPK after the initial readiness cooldown.
for _ in 0..40 + launch::OPEN_TICKS {
radar_sees(&mut sim);
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
@@ -695,6 +709,53 @@ mod tests {
assert!(sim.sams[0].fired > 0, "the closer target was not fired at");
}
#[test]
fn cold_launch_opens_before_spending_and_lights_only_after_ejection() {
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
sim.last_chance = false;
sim.sams[0].cooldown = 0;
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 160.0, -300.0), DVec3::Z, DVec3::ZERO);
let initial = sim.sams[0].missiles;
for _ in 0..200 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
if sim.sams[0].preparing.is_some() { break; }
}
assert!(sim.sams[0].preparing.is_some(), "a real tracked target must open a TPK");
assert_eq!(sim.sams[0].missiles, initial);
assert!(sim.missiles.is_empty());
// Loss during opening cancels without creating or consuming a missile.
sim.sams[0].health = 0.0;
sim.step(&mut ground, None, &mut out);
assert!(sim.sams[0].preparing.is_none() && sim.missiles.is_empty());
assert_eq!(sim.sams[0].missiles, initial);
sim.sams[0].health = 1.0;
sim.sams[0].cooldown = 0;
for _ in 0..200 {
out = Out::default();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
if !sim.missiles.is_empty() { break; }
}
let m = sim.missiles.first().expect("ejection after reopening");
let (id, kind) = (m.id, m.kind);
assert_eq!(sim.sams[0].missiles, initial - 1);
assert_eq!(sim.sams[0].spent_cells.count_ones(), 1);
assert!(!m.engine_lit());
assert!(!out.engines.iter().any(|e| e.key == 0x4000_0000 | id));
let mut last_speed = m.vel.length();
for _ in 0..kind.lit_after() + 3 {
out = Out::default();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
let m = sim.missiles.iter().find(|m| m.id == id).expect("cold launch remains in flight");
let voice = out.engines.iter().any(|e| e.key == 0x4000_0000 | id);
assert_eq!(voice, m.engine_lit());
if m.engine_lit() { assert!(m.vel.length() > last_speed, "the lit motor must accelerate"); }
last_speed = m.vel.length();
}
}
#[test]
fn the_kill_chance_falls_to_half_at_the_far_end() {
assert_eq!(reach_fit(100.0, 80.0), 1.0);
+9 -3
View File
@@ -228,11 +228,16 @@ fn recorded_trails(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSegm
|| a.distance(b) >= 400.0
|| (matches!(tr.what, What::StrikeCarrier(_))
&& born < tr.first as u64 + crate::sim::strike::EJECT_TICKS)
|| (matches!(tr.what, What::Missile(kind, _) if !rec.missile_is_lit(tr, born as f64, kind.lit_after())))
{
continue;
}
if matches!(tr.what, What::StrikeCarrier(_)) {
let ignition = tr.first as f64 + crate::sim::strike::EJECT_TICKS as f64;
let ignition = match tr.what {
What::StrikeCarrier(_) => Some(tr.first as f64 + crate::sim::strike::EJECT_TICKS as f64),
What::Missile(kind, _) => Some(rec.missile_ignition(tr.id).map_or(tr.first as f64 + kind.lit_after() as f64 + 1.0, |e| e.at as f64)),
_ => None,
};
if let Some(ignition) = ignition {
let segment_start = tr.first as f64 + (g - 1) as f64 * stride;
if segment_start < ignition { a = tr.at(rec.origin, ignition).unwrap_or(b); }
}
@@ -323,7 +328,8 @@ impl Smoke {
drone_look(spec.class, spec.trail_scale()),
);
}
MoverKind::Missile(kind) => {
MoverKind::Missile { kind, engine_lit } => {
if !engine_lit { continue; }
if let Some(look) = missile_look(kind) {
self.trail((mover.group, mover.id), mover.pos, tick, look);
}
+19
View File
@@ -31,6 +31,25 @@ expect.feedback = "С-400 №2: загружена RM48U (игровой про
command = "airdef ground-unload 2"
expect.feedback = "С-400 №2: выгружена RM48U; ударных 0, ПВО 4"
# Split battery configuration crosses the same saved command boundary.
[[step]]
command = "airdef battery 2 launcher"
expect.feedback = "№2 С-400: режим launcher"
[[step]]
command = "airdef deploy radar_track"
[[step]]
command = "airdef radar 4 search"
expect.feedback = "№4 РЛС: роль search"
[[step]]
command = "airdef radar 4 engagement"
expect.feedback = "№4 РЛС: роль engagement"
[[step]]
command = "airdef deploy s500_launcher"
[[step]]
command = "airdef wave mix 40 1500"
+90
View File
@@ -0,0 +1,90 @@
# Раздельная батарея и холодный пуск
## Из исследования в игру
Вместо «всё на одной машине» добавлена конфигурация с отдельными пусковыми и общими РЛС.
Высокоуровневое разделение обзорной и огневой РЛС соответствует публичным описаниям системы;
это не полная модель конкретной штатной батареи.
| Публично описываемый элемент С-400 | Его смысл | Игровая адаптация |
|---|---|---|
| 91Н6: обзор/обнаружение | Общая воздушная картина | `radar_search`: больший игровой обзор, передача наблюдений |
| 92Н6: сопровождение/управление огнём | Отдельный радар огневого комплекса | `radar_track`: более частые собственные измерения, поддержка нескольких ПУ |
| Дополнительная 96Л6 и антенные мачты | Дополнительные средства обнаружения | `radar_height`: условная мачтовая роль, улучшенный игровой радиогоризонт; это не копия 96Л6 |
| Командный пункт 55К6 | Управление и обмен данными | Пока абстрагирован существующей сетью и распределением целей, отдельной машины КП нет |
| Пусковая с ТПК | Боекомплект и физический пуск | `s400_launcher` / `s500_launcher`, без навесной РЛС |
Источники для архитектуры: оригинальная техническая публикация
[Bundesheer, «Radars für Raketenabwehr»](https://www.bmlv.gv.at/pdf_pool/publikationen/7_rosmann.pdf)
разделяет обзорный 91N6 и огневой 92N6; статья об С-400 в официальном
[Memorial de Artillería 174/2, Ministerio de Defensa](https://publicaciones.defensa.gob.es/media/downloadable/files/links/m/e/memorial_artilleria_174-2.pdf)
перечисляет КП, радары, пусковые и дополнительные средства. Это публичные обзоры,
не эксплуатационная документация изготовителя. У чисел времени, дальности и качества в моде
собственная игровая шкала; из этих публикаций они не калибровались.
Для наглядности холодного старта найден официальный выпуск
[«С-400. Секрет ракеты», «Военная приёмка»](https://rutube.ru/video/37a2ef7d5e83ffa9ec4ba222bfd8ce5f/)
с участием завода «Авангард». Доступная страница выпуска не содержит стенограммы;
форма шарнира крышки и длительность игровых стадий не объявляются воспроизведением механизма прототипа.
Для С-500 применяется стилизация существующего игрового профиля, без утверждения о механике всех реальных ракет.
## Как собрать
Через N → «Разместить элементы батареи…» можно расставить элементы в разных местах.
Командные эквиваленты:
```text
/airdef deploy radar_search
/airdef deploy radar_track
/airdef deploy s400_launcher
/airdef deploy s500_launcher
/airdef deploy radar_height
/airdef battery
```
Нумерацию существующей техники показывает `/airdef battery`. Чтобы переоборудовать её:
```text
/airdef battery <номер С-400 или С-500> launcher
/airdef radar <номер РЛС> search
/airdef radar <номер другой РЛС> engagement
```
Можно оставить одну многофункциональную РЛС (`multi`) и несколько пусковых.
Поисковая/мачтовая РЛС даёт подсказку, но не разрешает огонь сама. РЛС сопровождения принимает
подсказку и должна **сама измерить** цель в своей игровой досягаемости и с учётом рельефа.
Чужой трек не превращается в собственное измерение. После этого связанные пусковые используют
общую картину и существующее распределение целей. Если РЛС сопровождения уничтожена, свёрнута,
потеряла связь или её измерение устарело, новые пуски прекращаются. Уже вылетевшие ракеты
продолжают штатную игровую логику связи/памяти/самонаведения.
Старые миры и обычные `/airdef deploy s400` / `s500` сохраняют сборную конфигурацию `integrated`.
Режимы, роли, боезапас и использованные контейнеры сохраняются с миром и сценариями.
У прежнего игрового С-500 был магазин на четыре ракеты при двух нарисованных ТПК;
теперь он согласован с двумя физическими ячейками. При загрузке старой расстановки
его загруженный остаток ограничивается новой вместимостью, как при обычном восстановлении боезапаса.
## Что видно при пуске
1. Подготовка резервирует конкретную ячейку. Отдельная крышка ТПК открывается; ракета ещё не расходуется.
2. При потере цели или неисправности/сворачивании носителя до выброса подготовка отменяется.
3. Ракета выбрасывается из фактического muzzle. Боекомплект уменьшается один раз; использованный ТПК остаётся открытым.
4. В фазе выброса отсутствуют факел, непрерывный звук двигателя и его дымовой след.
5. После зажигания включаются эффекты, ракета разгоняется и начинает поворот по существующей игровой логике.
Это действует для зенитных С-400/С-500 и подготовленного ударного RM48U у С-400.
У ручного удара носитель и ячейка резервируются до пуска; изменение состояния отменяет подготовку
без расхода. Для RM48U добавлен плавный разгон после выброса. Задержанный ручной приказ
не возобновляется после загрузки мира: незапущенный боеприпас остаётся в носителе.
В записи сохраняются конфигурация машины, использованные ячейки и положение крышки;
повтор восстанавливает их по своей временной шкале. Записанный дым начинается у точки зажигания,
а не рисуется задним числом от горловины контейнера.
## Ограничения
Роли РЛС и мачта пока используют общую модель машины, с разным поведением/отображением антенны.
Отдельные точные модели 91Н6/92Н6/96Л6, машина КП, ручная принадлежность к батарее, отдельные
каналы сопровождения и кабельное снабжение пока не добавлены. Подробные режимы и параметры реальных
комплексов не моделируются. Конечная работа двигателя и энергетика манёвра остаются в #120;
доставка ударных ракет через ТЗМ остаётся в #118.
+5 -1
View File
@@ -20,7 +20,7 @@
| #114 | Навигация учитывает footprint и окончательную authored-геометрию; остановка перед unread frontier; угловые пересечения | Массовая игровая проверка всех новых шасси на сложном рельефе |
| #115 | Три пилота с кадрами до/после, спокойные материалы; authored-проход по боевым и вспомогательным машинам | Крупные планы и точность прототипов ещё промежуточные; остальные летательные аппараты/ракеты, анимации, дальние виды и FPS |
| #116 | Streaming не освобождает busy-guard работающего acoustic worker; проверено engine-тестами | Внешняя игровая приёмка на целевых машинах |
| #118 | Ручной пуск из физического С-400 с отдельным RM48U; общие четыре ячейки, конечный склад, обмен/возврат; carrier в записи, холодный выброс и зажигание; фантомное ПВО удалено | Доставка RM48U через ТЗМ, расширение подтверждённых игровых профилей, включая оценку Бука; визуальная приёмка самого пуска |
| #118 | Ручной пуск из физического С-400 с отдельным RM48U; общие четыре ячейки, конечный склад, обмен/возврат; carrier в записи, подготовка/крышки ТПК, холодный выброс, зажигание и игровой разгон; фантомное ПВО удалено | Доставка RM48U через ТЗМ, расширение подтверждённых игровых профилей, включая оценку Бука; визуальная приёмка самого пуска |
Для #118 RM48U — явно условный Minecraft-профиль. Существование сочетания опирается на сообщение ведомства,
а игровые параметры не выдаются за реальные. `ground-stock` — команда подготовки sandbox-сценария,
@@ -28,3 +28,7 @@
Новые находки внешнего ревью и причины отказа от неверных замечаний: [REVIEW-FOLLOWUP-2026-11-02.md](REVIEW-FOLLOWUP-2026-11-02.md).
Картинки и ограничения художественного прохода: [MODEL-PILOTS.md](MODEL-PILOTS.md).
Дополнительно по просьбе владельца: [раздельная батарея и холодный пуск](BATTERIES-AND-COLD-LAUNCH.md).
Есть отдельные ПУ С-400/С-500, роли общей РЛС, цепочка подсказка → собственное измерение → пуск,
сохранение конфигурации и запись крышек. Это не закрывает оставшиеся пункты художественной и платформенной приёмки.
+36 -6
View File
@@ -33,9 +33,10 @@ NS = "airdef"
# ---------------------------------------------------------------- модели
def el(frm, to, tex, faces=None, rot=None):
def el(frm, to, tex, faces=None, rot=None, omit_faces=()):
"""Элемент; rot = (ось, угол) — поворот вокруг центра элемента."""
return {"from": [float(v) for v in frm], "to": [float(v) for v in to], "tex": tex, "faces": faces, "rot": rot}
return {"from": [float(v) for v in frm], "to": [float(v) for v in to], "tex": tex,
"faces": faces, "rot": rot, "omit_faces": tuple(omit_faces)}
def mx(e):
@@ -90,6 +91,8 @@ def model(parts, burnt=False):
for p in parts:
faces = {}
for f in ("north", "south", "east", "west", "up", "down"):
if f in p.get("omit_faces", ()):
continue
tex = (p["faces"] or {}).get(f, p["tex"])
uv = face_uv(p["from"], p["to"], f)
dx, dy, dz = (abs(p["to"][i] - p["from"][i]) for i in range(3))
@@ -394,7 +397,7 @@ def maz_chassis(tex):
return p + wheels((-14, -5, 13, 22))
def tubes(n_wide, n_high, size, tex="tube"):
def tubes(n_wide, n_high, size, tex="tube", front_cap=True):
"""Пакет ТПК: n_wide × n_high труб сечением size, от z=-16 до 32. Ось подъёма — задняя нижняя кромка."""
p = []
w = n_wide * size + (n_wide - 1)
@@ -403,7 +406,12 @@ def tubes(n_wide, n_high, size, tex="tube"):
for j in range(n_high):
x = x0 + i * (size + 1)
y = j * (size + 1)
p.append(el((x, y, -16), (x + size, y + size, 32), tex, {"north": "tube_cap", "south": "tube_cap"}))
if front_cap:
p.append(el((x, y, -16), (x + size, y + size, 32), tex,
{"north": "tube_cap", "south": "tube_cap"}))
else:
p.append(el((x, y, -16), (x + size, y + size, 32), tex,
{"south": "tube_cap"}, omit_faces=("north",)))
p += [el((x0 - 1, 0, 26), (x0 + w + 1, n_high * (size + 1) - 1, 28), "dark_metal"),
el((x0 - 1, 0, -2), (x0 + w + 1, n_high * (size + 1) - 1, 0), "dark_metal")]
return p
@@ -421,6 +429,26 @@ def big_radar_panel():
el((6, -6, 8), (10, 0, 12), "metal")]
def tpk_cap():
"""Reusable stylized octagonal cap, centered at (8, 8, 8), for animated TPK covers."""
z0, z_face, z1 = 7.7, 8.25, 8.3
# Three cuboids form a flat octagonal silhouette while keeping the geometry blocky.
return [el((2.5, 0, z0), (13.5, 16, z_face), "tube_cap"),
el((0, 2.5, z0), (2.5, 13.5, z_face), "tube_cap"),
el((13.5, 2.5, z0), (16, 13.5, z_face), "tube_cap"),
el((5, 5, z_face), (11, 11, z1), "dark_metal")]
def radar_mast():
"""Generic two-block telescoping radar mast segment; stackable for role-based height."""
return [el((4, 0, 4), (12, 3, 12), "dark_metal"),
el((5.5, 3, 5.5), (10.5, 18, 10.5), "metal"),
el((4.5, 18, 4.5), (11.5, 21, 11.5), "dark_metal"),
el((6.5, 21, 6.5), (9.5, 30, 9.5), "dark_metal"),
el((5.5, 30, 5.5), (10.5, 32, 10.5), "metal"),
el((7, 31.5, 7), (9, 32, 9), "dark_metal")]
def missile_57e6():
p = taper("missile_ru", -16, -12, 0.5, 2, 0.5, 2, 2)
p += [el((7, 7, -12), (9, 9, 4), "missile_ru"), el((6.4, 6.4, 4), (9.6, 9.6, 28), "booster")]
@@ -997,7 +1025,9 @@ MODELS = {
"s400_chassis": (lambda: maz_chassis("ru_green"), "Шасси С-400"),
"s400_tubes": (lambda: tubes(2, 2, 7), "ТПК 48Н6"),
"s500_chassis": (lambda: maz_chassis("ru_khaki"), "Шасси С-500"),
"s500_tubes": (lambda: tubes(2, 1, 9.5), "ТПК 77Н6"),
"s500_tubes": (lambda: tubes(2, 1, 9.5, front_cap=False), "ТПК 77Н6"),
"tpk_cap": (tpk_cap, "Крышка ТПК (анимация)"),
"radar_mast": (radar_mast, "Радиомачта (секция высоты)"),
"radar_trailer": (radar_trailer, "Прицеп РЛС"),
"radar_panel": (big_radar_panel, "РЛС обзора"),
"m57e6": (missile_57e6, "57Э6"),
@@ -1039,7 +1069,7 @@ MODELS = {
}
WRECKS = ["patriot_trailer", "patriot_launcher", "gepard_hull", "gepard_turret", "gepard_guns", "pantsir_truck",
"pantsir_turret", "pantsir_pack", "s400_chassis", "s400_tubes", "s500_chassis", "s500_tubes",
"pantsir_turret", "pantsir_pack", "s400_chassis", "s400_tubes", "s500_chassis", "s500_tubes", "tpk_cap", "radar_mast",
"irist_chassis", "irist_tubes", "nasams_trailer", "nasams_launcher", "buk_chassis", "buk_turret",
"buk_rails", "tor_chassis", "tor_turret", "p1sun_launcher", "radar_truck", "depot", "tzm_truck",
"ew_truck", "searchlight", "searchlight_head"]
+1 -1
View File
@@ -56,7 +56,7 @@ def s400_tubes():
for lo, hi, width in [(-1, -.6, .6), (-.6, -.2, .92), (-.2, .2, 1), (.2, .6, .92), (.6, 1, .6)]:
parts.append(g.el((x + 3.5 - 3.5 * width, y + 3.5 + lo * 3.5, -16),
(x + 3.5 + 3.5 * width, y + 3.5 + hi * 3.5, 32),
"tube", {"north": "tube_cap", "south": "tube_cap"}))
"tube", {"south": "tube_cap"}, omit_faces=("north",)))
for z in [-1, 26]:
parts.append(g.el((x - .15, y + 2.6, z), (x + 7.15, y + 4.4, z + 1), "dark_metal"))
parts += [g.el((-.5, 0, 26), (16.5, 1, 29), "dark_metal")]