diff --git a/README.md b/README.md index 6f986d8..d5e59fb 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,9 @@ API мода `render.set-lights` (до 512, плавно интерполиру - `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт; - `/airdef lastchance ` — «последний шанс»: комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт (по умолчанию вкл; сохраняется); - `/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 <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`; - `/airdef doctrine` — доктрины комплексов (#47; в меню N: «Доктрины…»): `/airdef doctrine <номер|all> reserve ` — держать K ракет для срочных целей (баллистика или до удара меньше 12 с); `salvo eco|normal|heavy` — сколько перехватчиков на цель (только базовые / как обычно / на один больше); `sector <центр°> <полуширина°>` или `sector off` — сектор ответственности (0° — север); `guard <объект№>[,<№>]` или `guard off` — стрелять только по тому, что идёт на охраняемые объекты (отметка: `/airdef target here`). По умолчанию ограничений нет; последний шанс сильнее доктрины; `/airdef why <номер>` называет, какая настройка остановила или разрешила пуск; сохраняется с миром и сценариями. Карта охраны и пробелов в ней — вместе с картой (#48). - `/airdef deploy ` — поставить комплекс в 8 блоках перед собой; diff --git a/airdef-mod/assets/airdef/blockstates/radar_mast.json b/airdef-mod/assets/airdef/blockstates/radar_mast.json new file mode 100644 index 0000000..193aa0b --- /dev/null +++ b/airdef-mod/assets/airdef/blockstates/radar_mast.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "airdef:block/radar_mast" + } + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/blockstates/radar_mast_burnt.json b/airdef-mod/assets/airdef/blockstates/radar_mast_burnt.json new file mode 100644 index 0000000..80b0021 --- /dev/null +++ b/airdef-mod/assets/airdef/blockstates/radar_mast_burnt.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "airdef:block/radar_mast_burnt" + } + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/blockstates/tpk_cap.json b/airdef-mod/assets/airdef/blockstates/tpk_cap.json new file mode 100644 index 0000000..2a23228 --- /dev/null +++ b/airdef-mod/assets/airdef/blockstates/tpk_cap.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "airdef:block/tpk_cap" + } + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/blockstates/tpk_cap_burnt.json b/airdef-mod/assets/airdef/blockstates/tpk_cap_burnt.json new file mode 100644 index 0000000..06f7da1 --- /dev/null +++ b/airdef-mod/assets/airdef/blockstates/tpk_cap_burnt.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "airdef:block/tpk_cap_burnt" + } + } +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/lang/en_us.json b/airdef-mod/assets/airdef/lang/en_us.json index e12583b..273340f 100644 --- a/airdef-mod/assets/airdef/lang/en_us.json +++ b/airdef-mod/assets/airdef/lang/en_us.json @@ -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 (остов)", diff --git a/airdef-mod/assets/airdef/lang/ru_ru.json b/airdef-mod/assets/airdef/lang/ru_ru.json index e12583b..273340f 100644 --- a/airdef-mod/assets/airdef/lang/ru_ru.json +++ b/airdef-mod/assets/airdef/lang/ru_ru.json @@ -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 (остов)", diff --git a/airdef-mod/assets/airdef/models/block/radar_mast.json b/airdef-mod/assets/airdef/models/block/radar_mast.json new file mode 100644 index 0000000..80e567d --- /dev/null +++ b/airdef-mod/assets/airdef/models/block/radar_mast.json @@ -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" + } + } + } + ] +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/models/block/radar_mast_burnt.json b/airdef-mod/assets/airdef/models/block/radar_mast_burnt.json new file mode 100644 index 0000000..868fb84 --- /dev/null +++ b/airdef-mod/assets/airdef/models/block/radar_mast_burnt.json @@ -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" + } + } + } + ] +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/models/block/s400_tubes.json b/airdef-mod/assets/airdef/models/block/s400_tubes.json index 9485df9..8c1a389 100644 --- a/airdef-mod/assets/airdef/models/block/s400_tubes.json +++ b/airdef-mod/assets/airdef/models/block/s400_tubes.json @@ -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, diff --git a/airdef-mod/assets/airdef/models/block/s500_tubes.json b/airdef-mod/assets/airdef/models/block/s500_tubes.json index f626dcd..cd94e74 100644 --- a/airdef-mod/assets/airdef/models/block/s500_tubes.json +++ b/airdef-mod/assets/airdef/models/block/s500_tubes.json @@ -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, diff --git a/airdef-mod/assets/airdef/models/block/s500_tubes_burnt.json b/airdef-mod/assets/airdef/models/block/s500_tubes_burnt.json index 3208fab..672ae13 100644 --- a/airdef-mod/assets/airdef/models/block/s500_tubes_burnt.json +++ b/airdef-mod/assets/airdef/models/block/s500_tubes_burnt.json @@ -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, diff --git a/airdef-mod/assets/airdef/models/block/tpk_cap.json b/airdef-mod/assets/airdef/models/block/tpk_cap.json new file mode 100644 index 0000000..4403e51 --- /dev/null +++ b/airdef-mod/assets/airdef/models/block/tpk_cap.json @@ -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" + } + } + } + ] +} \ No newline at end of file diff --git a/airdef-mod/assets/airdef/models/block/tpk_cap_burnt.json b/airdef-mod/assets/airdef/models/block/tpk_cap_burnt.json new file mode 100644 index 0000000..a0b1e65 --- /dev/null +++ b/airdef-mod/assets/airdef/models/block/tpk_cap_burnt.json @@ -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" + } + } + } + ] +} \ No newline at end of file diff --git a/airdef-mod/data/airdef/horizon/block/radar_mast.json b/airdef-mod/data/airdef/horizon/block/radar_mast.json new file mode 100644 index 0000000..47be203 --- /dev/null +++ b/airdef-mod/data/airdef/horizon/block/radar_mast.json @@ -0,0 +1,4 @@ +{ + "template": "minecraft:stone", + "item": false +} \ No newline at end of file diff --git a/airdef-mod/data/airdef/horizon/block/radar_mast_burnt.json b/airdef-mod/data/airdef/horizon/block/radar_mast_burnt.json new file mode 100644 index 0000000..47be203 --- /dev/null +++ b/airdef-mod/data/airdef/horizon/block/radar_mast_burnt.json @@ -0,0 +1,4 @@ +{ + "template": "minecraft:stone", + "item": false +} \ No newline at end of file diff --git a/airdef-mod/data/airdef/horizon/block/tpk_cap.json b/airdef-mod/data/airdef/horizon/block/tpk_cap.json new file mode 100644 index 0000000..47be203 --- /dev/null +++ b/airdef-mod/data/airdef/horizon/block/tpk_cap.json @@ -0,0 +1,4 @@ +{ + "template": "minecraft:stone", + "item": false +} \ No newline at end of file diff --git a/airdef-mod/data/airdef/horizon/block/tpk_cap_burnt.json b/airdef-mod/data/airdef/horizon/block/tpk_cap_burnt.json new file mode 100644 index 0000000..47be203 --- /dev/null +++ b/airdef-mod/data/airdef/horizon/block/tpk_cap_burnt.json @@ -0,0 +1,4 @@ +{ + "template": "minecraft:stone", + "item": false +} \ No newline at end of file diff --git a/airdef-mod/generated-manifest.txt b/airdef-mod/generated-manifest.txt index d0bd1f5..62e48e5 100644 --- a/airdef-mod/generated-manifest.txt +++ b/airdef-mod/generated-manifest.txt @@ -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 diff --git a/airdef-mod/source-assets/assets/airdef/models/block/s400_tubes.json b/airdef-mod/source-assets/assets/airdef/models/block/s400_tubes.json index 9485df9..8c1a389 100644 --- a/airdef-mod/source-assets/assets/airdef/models/block/s400_tubes.json +++ b/airdef-mod/source-assets/assets/airdef/models/block/s400_tubes.json @@ -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, diff --git a/airdef-mod/src/commands.rs b/airdef-mod/src/commands.rs index 79d3f32..aa3d9f6 100644 --- a/airdef-mod/src/commands.rs +++ b/airdef-mod/src/commands.rs @@ -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"]); diff --git a/airdef-mod/src/layout.rs b/airdef-mod/src/layout.rs index 2cfccd0..d10c563 100644 --- a/airdef-mod/src/layout.rs +++ b/airdef-mod/src/layout.rs @@ -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::().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() } diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 4b6b7c9..d1285ef 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -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::>().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 { + 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::().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::().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()); diff --git a/airdef-mod/src/map.rs b/airdef-mod/src/map.rs index f3c16ac..fc374c9 100644 --- a/airdef-mod/src/map.rs +++ b/airdef-mod/src/map.rs @@ -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)); diff --git a/airdef-mod/src/scene.rs b/airdef-mod/src/scene.rs index 9bb2adb..35502e3 100644 --- a/airdef-mod/src/scene.rs +++ b/airdef-mod/src/scene.rs @@ -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 { diff --git a/airdef-mod/src/sim/battery.rs b/airdef-mod/src/sim/battery.rs new file mode 100644 index 0000000..8af5cc9 --- /dev/null +++ b/airdef-mod/src/sim/battery.rs @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/airdef-mod/src/sim/generated_bounds.rs b/airdef-mod/src/sim/generated_bounds.rs index 3bbecdb..e915677 100644 --- a/airdef-mod/src/sim/generated_bounds.rs +++ b/airdef-mod/src/sim/generated_bounds.rs @@ -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]), diff --git a/airdef-mod/src/sim/launch.rs b/airdef-mod/src/sim/launch.rs new file mode 100644 index 0000000..c3ae25c --- /dev/null +++ b/airdef-mod/src/sim/launch.rs @@ -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); + } +} diff --git a/airdef-mod/src/sim/logistics.rs b/airdef-mod/src/sim/logistics.rs index 23c84ae..26617c6 100644 --- a/airdef-mod/src/sim/logistics.rs +++ b/airdef-mod/src/sim/logistics.rs @@ -23,7 +23,7 @@ impl Sim { pub fn load_ground_round(&mut self, i: usize) -> Result { 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 { 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) diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index d222ebe..cfc344f 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -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, + 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; diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 244beda..a938533 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -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) -> (Sim, u32, DVec3) { use crate::sim::sites::{LaunchSite, SiteClass}; use crate::sim::strike::{Estimate, Known, SiteState}; diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index 6c0238b..70516c5 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -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>, + 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) -> Option { 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 = - (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 = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect(); + let alive: std::collections::HashSet = 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> = @@ -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)> = 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::>())) .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(); diff --git a/airdef-mod/src/sim/strike.rs b/airdef-mod/src/sim/strike.rs index ab23e89..55adbc9 100644 --- a/airdef-mod/src/sim/strike.rs +++ b/airdef-mod/src/sim/strike.rs @@ -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, pub flights: Vec, + pub(super) pending: Vec, flown: Vec, 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 { let mut v: Vec = self.strikes.flights.iter().map(|f| f.site).collect(); + v.extend(self.strikes.pending.iter().map(|p| p.site)); let alive: Vec = 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 { + /// 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 { 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); diff --git a/airdef-mod/src/sim/units.rs b/airdef-mod/src/sim/units.rs index d88d59c..25d04eb 100644 --- a/airdef-mod/src/sim/units.rs +++ b/airdef-mod/src/sim/units.rs @@ -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)], diff --git a/airdef-mod/src/sim/why.rs b/airdef-mod/src/sim/why.rs index 3dbcc63..909bc95 100644 --- a/airdef-mod/src/sim/why.rs +++ b/airdef-mod/src/sim/why.rs @@ -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 { + 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); diff --git a/airdef-mod/src/smoke.rs b/airdef-mod/src/smoke.rs index c1535ab..f08d3e7 100644 --- a/airdef-mod/src/smoke.rs +++ b/airdef-mod/src/smoke.rs @@ -228,11 +228,16 @@ fn recorded_trails(rec: &crate::sim::record::Recording, t: f64) -> Vec= 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); } diff --git a/airdef-mod/tests/wasm_contract.toml b/airdef-mod/tests/wasm_contract.toml index 3a109c2..c88f4a7 100644 --- a/airdef-mod/tests/wasm_contract.toml +++ b/airdef-mod/tests/wasm_contract.toml @@ -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" diff --git a/docs/BATTERIES-AND-COLD-LAUNCH.md b/docs/BATTERIES-AND-COLD-LAUNCH.md new file mode 100644 index 0000000..17b3902 --- /dev/null +++ b/docs/BATTERIES-AND-COLD-LAUNCH.md @@ -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. diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index fea8548..ee47d2c 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -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, роли общей РЛС, цепочка подсказка → собственное измерение → пуск, +сохранение конфигурации и запись крышек. Это не закрывает оставшиеся пункты художественной и платформенной приёмки. diff --git a/tools/gen_models.py b/tools/gen_models.py index 196de37..a8905d2 100644 --- a/tools/gen_models.py +++ b/tools/gen_models.py @@ -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"] diff --git a/tools/pilot_models.py b/tools/pilot_models.py index bc00093..3a1b48f 100644 --- a/tools/pilot_models.py +++ b/tools/pilot_models.py @@ -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")]