Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65dd84646a | |||
| ed2db8523b | |||
| 67a2a523b4 | |||
| a806635681 | |||
| 0092ec64a9 | |||
| e08fc65ac8 | |||
| 90379f6e17 | |||
| bb35975642 |
@@ -28,6 +28,13 @@ jobs:
|
||||
# падало по таймауту, а сохранение вешало «Complete job» на пару минут. Инструмент собирается заново.
|
||||
- name: Форк Horizon
|
||||
run: nice -n 19 python3 tools/airdef.py fork
|
||||
# Чистая симуляция тестируется нативно (хост-таргет раннера, без WASM): регрессии из sim/regress.rs
|
||||
# и сценарии tests/scenarios.rs (NaN, налёт кончается, ТЗМ не зависают). Нужен форк: SDK подключён путём.
|
||||
- name: Тесты мода
|
||||
working-directory: airdef-mod
|
||||
run: |
|
||||
HOST=$(rustc -vV | sed -n 's/^host: //p')
|
||||
nice -n 19 cargo test --target "$HOST"
|
||||
- name: Мод
|
||||
run: nice -n 19 python3 tools/airdef.py mod
|
||||
- name: Релиз
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
## Запуск
|
||||
|
||||
Пошаговый гайд для Windows и macOS (готовая сборка из релиза или сборка из исходников): [docs/INSTALL.md](docs/INSTALL.md).
|
||||
Устройство HD-рендера, его статус и план доработки: [docs/HD-RENDERER.md](docs/HD-RENDERER.md).
|
||||
|
||||
Коротко, из исходников:
|
||||
|
||||
@@ -23,9 +24,10 @@ python3 tools/airdef.py play # запустить (аргументы игр
|
||||
(`tools/gen_sounds.py`, нужны numpy и ffmpeg), собирает и ставит мод. Сгенерированные файлы лежат в репозитории,
|
||||
так что для обычной сборки нужен только Rust.
|
||||
|
||||
CI: `.gitea/workflows/build.yml` на каждый пуш собирает мод и игру для Linux, а пуш в `main` публикует релиз
|
||||
`build-N`; `.gitea/workflows/desktop.yml` (запуск вручную с тегом релиза) собирает игру для macOS и Windows на
|
||||
раннерах с метками `macos-latest` / `windows-latest` и дописывает архивы в релиз.
|
||||
CI: `.gitea/workflows/build.yml` на каждый пуш собирает только мод (`airdef.hmod`), а пуш в `main` публикует его
|
||||
релизом `build-N`. Игру-форк собирают вручную: `.gitea/workflows/game.yml` (Linux) и `.gitea/workflows/desktop.yml`
|
||||
(macOS и Windows, на раннерах с метками `macos-latest` / `windows-latest`); оба запускаются из Actions с тегом
|
||||
готового релиза и дописывают свои архивы в него.
|
||||
|
||||
## Как играть
|
||||
|
||||
@@ -130,7 +132,7 @@ API мода `render.set-lights` (до 512, плавно интерполиру
|
||||
перехватчиков (жёлтые), сбития (x), прилёты, шкала времени с событиями; время идёт само, колесо мыши перематывает;
|
||||
- **повтор момента** — клик по событию в списке: «призраки» дронов и ракет снова летят в мире, камера идёт за
|
||||
целью, потом за перехватчиком (или смотрит из пушки, со стороны на прилёт), в момент сбития время замедляется;
|
||||
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — скорость (×0.5–×16);
|
||||
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — перемотка, стрелки — скорость;
|
||||
- **лучшие моменты** — нарезка до 8 самых интересных событий (перехват баллистики и гиперзвука, поражения
|
||||
объектов, потерянные комплексы, разделение «Орешника») с титрами и замедлением; колесо листает моменты.
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "airdef:block/amraam_missile"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "airdef:block/irist_missile"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
"block.airdef.geran3": "Герань-3",
|
||||
"block.airdef.geran3_flame": "Факел",
|
||||
"block.airdef.pac3": "PAC-3 MSE",
|
||||
"block.airdef.irist_missile": "IRIS-T SLM",
|
||||
"block.airdef.amraam_missile": "AMRAAM",
|
||||
"block.airdef.flame": "Факел",
|
||||
"block.airdef.patriot_launcher": "ПУ M903",
|
||||
"block.airdef.patriot_truck": "Тягач",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"block.airdef.geran3": "Герань-3",
|
||||
"block.airdef.geran3_flame": "Факел",
|
||||
"block.airdef.pac3": "PAC-3 MSE",
|
||||
"block.airdef.irist_missile": "IRIS-T SLM",
|
||||
"block.airdef.amraam_missile": "AMRAAM",
|
||||
"block.airdef.flame": "Факел",
|
||||
"block.airdef.patriot_launcher": "ПУ M903",
|
||||
"block.airdef.patriot_truck": "Тягач",
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
{
|
||||
"ambientocclusion": false,
|
||||
"textures": {
|
||||
"nose_dark": "airdef:block/nose_dark",
|
||||
"white": "airdef:block/white",
|
||||
"nozzle": "airdef:block/nozzle",
|
||||
"particle": "airdef:block/nose_dark"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"from": [
|
||||
7.333333333333333,
|
||||
7.333333333333333,
|
||||
-16.0
|
||||
],
|
||||
"to": [
|
||||
8.666666666666666,
|
||||
8.666666666666666,
|
||||
-13.666666666666666
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
-13.666666666666666
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
-11.333333333333332
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333334,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.333333333333334
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.666666666666666,
|
||||
6.666666666666666,
|
||||
-11.333333333333332
|
||||
],
|
||||
"to": [
|
||||
9.333333333333334,
|
||||
9.333333333333334,
|
||||
-9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333332,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.333333333333332,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.333333333333332
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.333333333333332
|
||||
],
|
||||
"texture": "#nose_dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.5,
|
||||
6.5,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
9.5,
|
||||
9.5,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
1.0,
|
||||
7.6,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
15.0,
|
||||
8.4,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
5.5,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
1.0,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
2.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
15.0,
|
||||
9.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
7.0,
|
||||
5.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
7.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
4.0,
|
||||
7.6,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
12.0,
|
||||
8.4,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.5,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
4.0,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
19.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
12.0,
|
||||
25.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
6.0,
|
||||
2.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
6.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
26.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
28.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
{
|
||||
"ambientocclusion": false,
|
||||
"textures": {
|
||||
"white": "airdef:block/white",
|
||||
"missile_grey": "airdef:block/missile_grey",
|
||||
"nozzle": "airdef:block/nozzle",
|
||||
"particle": "airdef:block/white"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"from": [
|
||||
7.333333333333333,
|
||||
7.333333333333333,
|
||||
-16.0
|
||||
],
|
||||
"to": [
|
||||
8.666666666666666,
|
||||
8.666666666666666,
|
||||
-14.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
1.333333333333333
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
1.333333333333333,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
-14.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
-12.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.666666666666666,
|
||||
6.666666666666666,
|
||||
-12.0
|
||||
],
|
||||
"to": [
|
||||
9.333333333333334,
|
||||
9.333333333333334,
|
||||
-10.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.666666666666668
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.666666666666668,
|
||||
2.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
6.5,
|
||||
6.5,
|
||||
-10.0
|
||||
],
|
||||
"to": [
|
||||
9.5,
|
||||
9.5,
|
||||
24.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
3.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.0,
|
||||
16
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
3.0,
|
||||
7.6,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
13.0,
|
||||
8.4,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
3.5,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
3.0,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
-9.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
13.0,
|
||||
-5.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
3.5
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.0
|
||||
],
|
||||
"texture": "#missile_grey"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
2.0,
|
||||
7.6,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
6.5,
|
||||
8.4,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
9.5,
|
||||
7.6,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
14.0,
|
||||
8.4,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
0.8000000000000007
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.5,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
2.0,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
6.5,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.6,
|
||||
9.5,
|
||||
18.0
|
||||
],
|
||||
"to": [
|
||||
8.4,
|
||||
14.0,
|
||||
26.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
8.0,
|
||||
4.5
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
0.8000000000000007,
|
||||
8.0
|
||||
],
|
||||
"texture": "#white"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
7.0,
|
||||
7.0,
|
||||
24.0
|
||||
],
|
||||
"to": [
|
||||
9.0,
|
||||
9.0,
|
||||
28.0
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"south": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"east": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"west": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
4.0,
|
||||
2.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"up": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
4.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
},
|
||||
"down": {
|
||||
"uv": [
|
||||
0,
|
||||
0,
|
||||
2.0,
|
||||
4.0
|
||||
],
|
||||
"texture": "#nozzle"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,413 @@
|
||||
//! Headless driver for the pure simulation (`airdef::sim`): builds a scenario, steps the
|
||||
//! simulation like the mod's tick does (answering ground queries from a height function),
|
||||
//! and measures. Shared by the `sim_run` example and `tests/scenarios.rs`.
|
||||
//!
|
||||
//! Native timings say nothing about the WASM build or the GPU; use them to compare runs of the
|
||||
//! same machine.
|
||||
#![allow(dead_code)]
|
||||
use airdef::sim::{Aim, Approach, DroneKind, Ground, Out, RaidOrder, Sam, SamKind, Sim};
|
||||
use glam::DVec3;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Height of the land under a column (what `render.ground` would answer).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Terrain {
|
||||
Flat(i32),
|
||||
/// Smooth hills: `base + amp * (sin(x/period) + cos(z/period)) / 2`.
|
||||
Hills { base: i32, amp: f64, period: f64 },
|
||||
}
|
||||
|
||||
impl Terrain {
|
||||
pub fn height(self, x: i32, z: i32) -> i32 {
|
||||
match self {
|
||||
Terrain::Flat(h) => h,
|
||||
Terrain::Hills { base, amp, period } => {
|
||||
base + (amp * ((f64::from(x) / period).sin() + (f64::from(z) / period).cos()) / 2.0).round() as i32
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RaidSpec {
|
||||
pub kinds: Vec<(DroneKind, u32)>,
|
||||
pub approach: Approach,
|
||||
pub aim: Aim,
|
||||
pub dist: f64,
|
||||
/// The tick the raid is ordered at (a raid waits for the previous one to be sent).
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Unit {
|
||||
pub kind: SamKind,
|
||||
pub pos: DVec3,
|
||||
pub heading: f64,
|
||||
/// Starts with an empty magazine, waiting for a reload (logistics).
|
||||
pub empty: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Scenario {
|
||||
pub name: String,
|
||||
pub seed: u64,
|
||||
pub units: Vec<Unit>,
|
||||
pub objects: Vec<DVec3>,
|
||||
pub raids: Vec<RaidSpec>,
|
||||
pub terrain: Terrain,
|
||||
/// Hard limit of ticks (20 a second).
|
||||
pub ticks: u64,
|
||||
/// Stop as soon as every raid has ended.
|
||||
pub stop_at_end: bool,
|
||||
pub ew: bool,
|
||||
/// Ticks a truck on a job may stand still before it counts as stuck.
|
||||
pub stuck_after: u64,
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Scenario {
|
||||
name: name.into(),
|
||||
seed: 1,
|
||||
units: Vec::new(),
|
||||
objects: vec![DVec3::new(0.0, 70.0, 0.0)],
|
||||
raids: Vec::new(),
|
||||
terrain: Terrain::Flat(70),
|
||||
ticks: 20 * 60 * 10,
|
||||
stop_at_end: true,
|
||||
ew: false,
|
||||
stuck_after: 600,
|
||||
}
|
||||
}
|
||||
pub fn unit(&mut self, kind: SamKind, x: f64, z: f64) -> &mut Unit {
|
||||
self.units.push(Unit { kind, pos: DVec3::new(x, 70.0, z), heading: 0.0, empty: false });
|
||||
self.units.last_mut().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Report {
|
||||
pub name: String,
|
||||
pub seed: u64,
|
||||
pub ticks_run: u64,
|
||||
pub units: usize,
|
||||
pub launched: u32,
|
||||
pub kills: u32,
|
||||
pub hits: u32,
|
||||
/// Missiles fired by air defence (sum of the systems' `fired`).
|
||||
pub fired: u32,
|
||||
pub ground_queries: u64,
|
||||
pub ground_batches: u64,
|
||||
pub tick_p50: Duration,
|
||||
pub tick_p95: Duration,
|
||||
pub tick_max: Duration,
|
||||
pub tick_total: Duration,
|
||||
/// Time spent in ticks that began with a raid being planned.
|
||||
pub planning: Duration,
|
||||
pub planning_ticks: u64,
|
||||
pub nan_positions: u64,
|
||||
/// Ids of trucks that stood on a job without progress for too long.
|
||||
pub stuck_trucks: Vec<u32>,
|
||||
/// Truck-ticks spent on a job or driving (shows the logistics actually ran).
|
||||
pub truck_busy_ticks: u64,
|
||||
pub raids_ordered: usize,
|
||||
pub raids_ended: usize,
|
||||
pub all_ended: bool,
|
||||
}
|
||||
|
||||
fn finite(v: DVec3) -> bool {
|
||||
v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
|
||||
}
|
||||
|
||||
pub fn run(sc: &Scenario) -> Report {
|
||||
let mut sim = Sim::new(sc.seed);
|
||||
sim.damage = false; // explosions would change the land the height function describes
|
||||
for u in &sc.units {
|
||||
let id = sim.id();
|
||||
let mut s = Sam::new(id, u.kind, u.pos, u.heading);
|
||||
if u.empty {
|
||||
s.empty = true;
|
||||
s.missiles = 0;
|
||||
}
|
||||
sim.sams.push(s);
|
||||
}
|
||||
sim.targets = sc.objects.clone();
|
||||
sim.target_health = vec![100.0; sc.objects.len()];
|
||||
if sc.ew {
|
||||
sim.set_ew(true);
|
||||
}
|
||||
let mut ground = Ground::default();
|
||||
let mut pending: Vec<(i32, i32, i32)> = Vec::new();
|
||||
let mut r = Report { name: sc.name.clone(), seed: sc.seed, units: sc.units.len(), ..Default::default() };
|
||||
let mut times: Vec<Duration> = Vec::with_capacity(sc.ticks as usize);
|
||||
let mut ordered = vec![false; sc.raids.len()];
|
||||
let mut launched_before = vec![0u32; sc.raids.len()];
|
||||
let mut ended = vec![false; sc.raids.len()];
|
||||
// truck id -> (last position, ticks standing while busy)
|
||||
let mut standing: HashMap<u32, (DVec3, u64)> = HashMap::new();
|
||||
let mut out = Out::default();
|
||||
for now in 1..=sc.ticks {
|
||||
// Raids come in order: one is ordered once the previous one has been sent.
|
||||
for (i, spec) in sc.raids.iter().enumerate() {
|
||||
if !ordered[i] && now >= spec.at && !sim.planning() && (i == 0 || ended[i - 1]) {
|
||||
let order = RaidOrder { kinds: spec.kinds.clone(), approach: spec.approach, dist: spec.dist, aim: spec.aim };
|
||||
launched_before[i] = sim.launched;
|
||||
sim.order_raid(&order, &sc.objects, &mut out);
|
||||
ordered[i] = true;
|
||||
}
|
||||
}
|
||||
ground.answer(std::mem::take(&mut pending), now);
|
||||
let planning = sim.planning();
|
||||
out = Out::default();
|
||||
let t = Instant::now();
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let wanted = ground.take_wanted();
|
||||
let dt = t.elapsed();
|
||||
times.push(dt);
|
||||
if planning {
|
||||
r.planning += dt;
|
||||
r.planning_ticks += 1;
|
||||
}
|
||||
if !wanted.is_empty() {
|
||||
r.ground_batches += 1;
|
||||
r.ground_queries += wanted.len() as u64;
|
||||
pending = wanted.into_iter().map(|(x, z)| (x, z, sc.terrain.height(x, z))).collect();
|
||||
}
|
||||
r.ticks_run = now;
|
||||
// NaN check on everything with a position.
|
||||
let bad = sim.drones.iter().filter(|d| !finite(d.pos) || !finite(d.vel)).count()
|
||||
+ sim.missiles.iter().filter(|m| !finite(m.pos)).count()
|
||||
+ sim.sams.iter().filter(|s| !finite(s.pos)).count()
|
||||
+ sim.blasts.iter().filter(|b| !finite(b.at)).count()
|
||||
+ out.models.iter().filter(|m| !finite(m.at)).count();
|
||||
r.nan_positions += bad as u64;
|
||||
// Trucks with a job or on the move, standing still.
|
||||
for s in sim.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()) {
|
||||
let busy = s.moving() || s.job().is_some();
|
||||
r.truck_busy_ticks += u64::from(busy);
|
||||
let e = standing.entry(s.id).or_insert((s.pos, 0));
|
||||
if !busy || e.0.distance(s.pos) > 0.05 {
|
||||
*e = (s.pos, 0);
|
||||
} else {
|
||||
e.1 += 1;
|
||||
if e.1 == sc.stuck_after && !r.stuck_trucks.contains(&s.id) {
|
||||
r.stuck_trucks.push(s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A raid has ended when it was ordered, planned, flown and nothing is left in the air.
|
||||
let quiet = !sim.planning() && sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()) && sim.missiles.is_empty();
|
||||
for i in 0..sc.raids.len() {
|
||||
if ordered[i] && !ended[i] && quiet && sim.launched > launched_before[i] {
|
||||
ended[i] = true;
|
||||
}
|
||||
}
|
||||
if sc.stop_at_end && ended.iter().all(|e| *e) && !sc.raids.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
r.launched = sim.launched;
|
||||
r.kills = sim.kills;
|
||||
r.hits = sim.hits;
|
||||
r.fired = sim.sams.iter().map(|s| s.fired).sum();
|
||||
r.raids_ordered = ordered.iter().filter(|o| **o).count();
|
||||
r.raids_ended = ended.iter().filter(|o| **o).count();
|
||||
r.all_ended = r.raids_ended == sc.raids.len();
|
||||
r.tick_total = times.iter().sum();
|
||||
times.sort();
|
||||
if !times.is_empty() {
|
||||
let at = |q: f64| times[((times.len() - 1) as f64 * q).round() as usize];
|
||||
r.tick_p50 = at(0.5);
|
||||
r.tick_p95 = at(0.95);
|
||||
r.tick_max = *times.last().unwrap();
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn ms(d: Duration) -> f64 {
|
||||
d.as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Problems that fail a scenario: empty when it is healthy.
|
||||
pub fn problems(&self) -> Vec<String> {
|
||||
let mut p = Vec::new();
|
||||
if self.nan_positions > 0 {
|
||||
p.push(format!("{} non-finite positions", self.nan_positions));
|
||||
}
|
||||
if !self.stuck_trucks.is_empty() {
|
||||
p.push(format!("stuck trucks: {:?}", self.stuck_trucks));
|
||||
}
|
||||
if !self.all_ended {
|
||||
p.push(format!("{} of {} raids ended in {} ticks", self.raids_ended, self.raids_ended.max(self.raids_ordered), self.ticks_run));
|
||||
}
|
||||
p
|
||||
}
|
||||
pub fn text(&self) -> String {
|
||||
format!(
|
||||
"scenario {name} (seed {seed}): {units} units, {ticks} ticks ({secs:.0} s of game time)\n\
|
||||
raids: {ro} ordered, {re} ended\n\
|
||||
launched {l}, shot down {k}, got through {h}, air-defence missiles fired {f}\n\
|
||||
tick time ms: p50 {p50:.3} p95 {p95:.3} max {max:.3} total {tot:.0}\n\
|
||||
planning: {pl:.1} ms over {pt} ticks\n\
|
||||
ground queries: {gq} in {gb} batches\n\
|
||||
non-finite positions: {nan}\n\
|
||||
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n",
|
||||
name = self.name,
|
||||
seed = self.seed,
|
||||
units = self.units,
|
||||
ticks = self.ticks_run,
|
||||
secs = self.ticks_run as f64 / 20.0,
|
||||
ro = self.raids_ordered,
|
||||
re = self.raids_ended,
|
||||
l = self.launched,
|
||||
k = self.kills,
|
||||
h = self.hits,
|
||||
f = self.fired,
|
||||
p50 = ms(self.tick_p50),
|
||||
p95 = ms(self.tick_p95),
|
||||
max = ms(self.tick_max),
|
||||
tot = ms(self.tick_total),
|
||||
pl = ms(self.planning),
|
||||
pt = self.planning_ticks,
|
||||
gq = self.ground_queries,
|
||||
gb = self.ground_batches,
|
||||
nan = self.nan_positions,
|
||||
stuck = self.stuck_trucks,
|
||||
busy = self.truck_busy_ticks,
|
||||
)
|
||||
}
|
||||
pub fn json(&self) -> String {
|
||||
format!(
|
||||
"{{\"scenario\":{:?},\"seed\":{},\"units\":{},\"ticks\":{},\"raids_ordered\":{},\"raids_ended\":{},\
|
||||
\"launched\":{},\"kills\":{},\"hits\":{},\"fired\":{},\
|
||||
\"tick_ms\":{{\"p50\":{:.4},\"p95\":{:.4},\"max\":{:.4},\"total\":{:.2}}},\
|
||||
\"planning_ms\":{:.2},\"planning_ticks\":{},\"ground_queries\":{},\"ground_batches\":{},\
|
||||
\"nan_positions\":{},\"stuck_trucks\":{:?},\"truck_busy_ticks\":{}}}",
|
||||
self.name,
|
||||
self.seed,
|
||||
self.units,
|
||||
self.ticks_run,
|
||||
self.raids_ordered,
|
||||
self.raids_ended,
|
||||
self.launched,
|
||||
self.kills,
|
||||
self.hits,
|
||||
self.fired,
|
||||
ms(self.tick_p50),
|
||||
ms(self.tick_p95),
|
||||
ms(self.tick_max),
|
||||
ms(self.tick_total),
|
||||
ms(self.planning),
|
||||
self.planning_ticks,
|
||||
self.ground_queries,
|
||||
self.ground_batches,
|
||||
self.nan_positions,
|
||||
self.stuck_trucks,
|
||||
self.truck_busy_ticks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ready-made scenarios
|
||||
|
||||
fn ring(sc: &mut Scenario, kinds: &[SamKind], count: usize, r0: f64, r1: f64) {
|
||||
for i in 0..count {
|
||||
let a = i as f64 * 2.399_963; // golden angle: an even spread without randomness
|
||||
let r = r0 + (r1 - r0) * (i as f64 / count.max(1) as f64);
|
||||
let kind = kinds[i % kinds.len()];
|
||||
sc.unit(kind, a.sin() * r, a.cos() * r).heading = a;
|
||||
}
|
||||
}
|
||||
|
||||
/// A big smart raid on 61 systems.
|
||||
pub fn big_smart() -> Scenario {
|
||||
let mut sc = Scenario::new("big-smart");
|
||||
sc.terrain = Terrain::Hills { base: 70, amp: 14.0, period: 90.0 };
|
||||
for (x, z) in [(0.0, 0.0), (120.0, 80.0), (-150.0, -60.0)] {
|
||||
sc.objects.push(DVec3::new(x, 70.0, z));
|
||||
}
|
||||
ring(&mut sc, &[SamKind::Radar], 6, 150.0, 500.0);
|
||||
ring(&mut sc, &[SamKind::Ew], 2, 100.0, 300.0);
|
||||
ring(&mut sc, &SamKind::ARMED, 53, 60.0, 450.0);
|
||||
assert_eq!(sc.units.len(), 61);
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![
|
||||
(DroneKind::Gerbera, 8),
|
||||
(DroneKind::Shahed, 12),
|
||||
(DroneKind::Geran3, 4),
|
||||
(DroneKind::Lancet, 4),
|
||||
(DroneKind::Kh101, 4),
|
||||
(DroneKind::Kalibr, 4),
|
||||
(DroneKind::Iskander, 2),
|
||||
],
|
||||
approach: Approach::AllSides,
|
||||
aim: Aim::Smart,
|
||||
dist: 500.0,
|
||||
at: 1,
|
||||
});
|
||||
sc.ticks = 20 * 60 * 8;
|
||||
sc
|
||||
}
|
||||
|
||||
/// Trucks and a depot supplying systems that start with empty magazines, under a raid.
|
||||
pub fn logistics() -> Scenario {
|
||||
let mut sc = Scenario::new("logistics");
|
||||
sc.unit(SamKind::Radar, 0.0, -40.0);
|
||||
sc.unit(SamKind::Depot, 300.0, 300.0);
|
||||
for (x, z) in [(310.0, 320.0), (290.0, 330.0)] {
|
||||
sc.unit(SamKind::Tzm, x, z);
|
||||
}
|
||||
sc.unit(SamKind::Tzm, 280.0, 280.0);
|
||||
for (i, kind) in [SamKind::Patriot, SamKind::Tor, SamKind::Buk, SamKind::Nasams, SamKind::Pantsir, SamKind::S400].into_iter().enumerate() {
|
||||
let a = i as f64 * 1.05;
|
||||
let u = sc.unit(kind, a.sin() * 120.0, a.cos() * 120.0);
|
||||
// Every other system starts out of missiles: the trucks have work from the first second.
|
||||
u.empty = i % 2 == 0;
|
||||
}
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![(DroneKind::Shahed, 16), (DroneKind::Gerbera, 8), (DroneKind::Kh101, 3)],
|
||||
approach: Approach::TwoSectors,
|
||||
aim: Aim::Objects,
|
||||
dist: 400.0,
|
||||
at: 1,
|
||||
});
|
||||
sc.ticks = 20 * 60 * 6;
|
||||
sc
|
||||
}
|
||||
|
||||
/// EW stations on and a smart enemy that adapts over three raids.
|
||||
pub fn ew_adaptive() -> Scenario {
|
||||
let mut sc = Scenario::new("ew-adaptive");
|
||||
sc.ew = true;
|
||||
sc.unit(SamKind::Radar, 0.0, 0.0);
|
||||
sc.unit(SamKind::Radar, 200.0, -200.0);
|
||||
sc.unit(SamKind::Ew, 80.0, 0.0);
|
||||
sc.unit(SamKind::Ew, -80.0, 60.0);
|
||||
for (i, kind) in [SamKind::Pantsir, SamKind::Gepard, SamKind::Tor, SamKind::Patriot, SamKind::Buk, SamKind::Gepard].into_iter().enumerate() {
|
||||
let a = i as f64 * 1.05;
|
||||
sc.unit(kind, a.sin() * 150.0, a.cos() * 150.0);
|
||||
}
|
||||
for at in [1, 1, 1] {
|
||||
sc.raids.push(RaidSpec {
|
||||
kinds: vec![(DroneKind::Gerbera, 6), (DroneKind::Shahed, 10), (DroneKind::Kh101, 4), (DroneKind::Kalibr, 3)],
|
||||
approach: Approach::AllSides,
|
||||
aim: Aim::Smart,
|
||||
dist: 450.0,
|
||||
at,
|
||||
});
|
||||
}
|
||||
sc.ticks = 20 * 60 * 12;
|
||||
sc
|
||||
}
|
||||
|
||||
pub fn preset(name: &str) -> Option<Scenario> {
|
||||
match name {
|
||||
"big-smart" => Some(big_smart()),
|
||||
"logistics" => Some(logistics()),
|
||||
"ew-adaptive" => Some(ew_adaptive()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub const PRESETS: [&str; 3] = ["big-smart", "logistics", "ew-adaptive"];
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Native headless runner for the simulation.
|
||||
//!
|
||||
//! cargo run --release --example sim_run -- [options]
|
||||
//!
|
||||
//! Options (all optional; a preset gives the defaults, the others override it):
|
||||
//! --scenario NAME big-smart | logistics | ew-adaptive (default big-smart)
|
||||
//! --seed N random seed
|
||||
//! --layout FILE systems from a text file, replacing the preset's; one per line:
|
||||
//! `KIND X Z [HEADING_RAD] [empty]` (`#` starts a comment)
|
||||
//! KIND is a system key (patriot, tor, radar, depot, tzm, ew, ...)
|
||||
//! --raid SPEC replaces the preset's raids; repeatable. SPEC is
|
||||
//! `kind=count,kind=count[;dist=500][;aim=smart|objects|sams][;approach=sector|two|all|waves][;at=TICK]`
|
||||
//! --terrain T flat[:HEIGHT] | hills[:BASE:AMP:PERIOD]
|
||||
//! --ticks N tick limit (20 a second)
|
||||
//! --run-on do not stop when the raids have ended
|
||||
//! --ew / --no-ew EW stations on or off
|
||||
//! --json one JSON object instead of text
|
||||
//! --list list scenarios, system and drone kinds
|
||||
//! Exit code 1 when the run has problems (NaN positions, stuck trucks, a raid that never ended).
|
||||
//! Native timings are for comparing runs on one machine, not an estimate of WASM/GPU cost.
|
||||
mod harness;
|
||||
use airdef::sim::{Aim, Approach, DroneKind, SamKind};
|
||||
use glam::DVec3;
|
||||
use harness::*;
|
||||
|
||||
fn die(msg: &str) -> ! {
|
||||
eprintln!("sim_run: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
fn parse_raid(spec: &str) -> RaidSpec {
|
||||
let mut r = RaidSpec { kinds: Vec::new(), approach: Approach::Sector, aim: Aim::Smart, dist: 400.0, at: 1 };
|
||||
for (i, part) in spec.split(';').enumerate() {
|
||||
if i == 0 {
|
||||
for kc in part.split(',').filter(|s| !s.is_empty()) {
|
||||
let (k, c) = kc.split_once('=').unwrap_or_else(|| die(&format!("raid kind `{kc}` needs =count")));
|
||||
let kind = DroneKind::from_key(k).unwrap_or_else(|| die(&format!("unknown drone kind `{k}` (see --list)")));
|
||||
r.kinds.push((kind, c.parse().unwrap_or_else(|_| die(&format!("bad count `{c}`")))));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (k, v) = part.split_once('=').unwrap_or_else(|| die(&format!("bad raid option `{part}`")));
|
||||
match k {
|
||||
"dist" => r.dist = v.parse().unwrap_or_else(|_| die("bad dist")),
|
||||
"at" => r.at = v.parse().unwrap_or_else(|_| die("bad at")),
|
||||
"aim" => {
|
||||
r.aim = match v {
|
||||
"smart" => Aim::Smart,
|
||||
"objects" => Aim::Objects,
|
||||
"sams" => Aim::ObjectsAndSams,
|
||||
_ => die("aim: smart | objects | sams"),
|
||||
}
|
||||
}
|
||||
"approach" => {
|
||||
r.approach = match v {
|
||||
"sector" => Approach::Sector,
|
||||
"two" => Approach::TwoSectors,
|
||||
"all" => Approach::AllSides,
|
||||
"waves" => Approach::Waves,
|
||||
_ => die("approach: sector | two | all | waves"),
|
||||
}
|
||||
}
|
||||
_ => die(&format!("unknown raid option `{k}`")),
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn parse_terrain(t: &str) -> Terrain {
|
||||
let n: Vec<&str> = t.split(':').collect();
|
||||
let num = |i: usize, d: f64| n.get(i).map_or(d, |s| s.parse().unwrap_or_else(|_| die("bad terrain number")));
|
||||
match n[0] {
|
||||
"flat" => Terrain::Flat(num(1, 70.0) as i32),
|
||||
"hills" => Terrain::Hills { base: num(1, 70.0) as i32, amp: num(2, 14.0), period: num(3, 90.0) },
|
||||
_ => die("terrain: flat[:H] | hills[:BASE:AMP:PERIOD]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_layout(path: &str) -> Vec<Unit> {
|
||||
let text = std::fs::read_to_string(path).unwrap_or_else(|e| die(&format!("{path}: {e}")));
|
||||
let mut units = Vec::new();
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let w: Vec<&str> = line.split_whitespace().collect();
|
||||
let bad = || die(&format!("{path}:{}: expected `KIND X Z [HEADING] [empty]`", n + 1));
|
||||
if w.len() < 3 {
|
||||
bad();
|
||||
}
|
||||
let kind = SamKind::from_key(w[0]).unwrap_or_else(|| die(&format!("{path}:{}: unknown system `{}`", n + 1, w[0])));
|
||||
let f = |s: &str| s.parse::<f64>().unwrap_or_else(|_| bad());
|
||||
let heading = w.get(3).filter(|s| **s != "empty").map_or(0.0, |s| f(s));
|
||||
units.push(Unit { kind, pos: DVec3::new(f(w[1]), 70.0, f(w[2])), heading, empty: w.contains(&"empty") });
|
||||
}
|
||||
units
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut name = "big-smart".to_string();
|
||||
let mut i = 0;
|
||||
// The scenario first, so the other options override it whatever their order.
|
||||
while i < args.len() {
|
||||
if args[i] == "--scenario" {
|
||||
name = args.get(i + 1).cloned().unwrap_or_else(|| die("--scenario needs a name"));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut sc = preset(&name).unwrap_or_else(|| die(&format!("unknown scenario `{name}`; one of {PRESETS:?}")));
|
||||
let (mut json, mut raids) = (false, Vec::new());
|
||||
let mut it = args.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
let mut value = || it.next().cloned().unwrap_or_else(|| die(&format!("{arg} needs a value")));
|
||||
match arg.as_str() {
|
||||
"--scenario" => {
|
||||
value();
|
||||
}
|
||||
"--seed" => sc.seed = value().parse().unwrap_or_else(|_| die("bad seed")),
|
||||
"--ticks" => sc.ticks = value().parse().unwrap_or_else(|_| die("bad ticks")),
|
||||
"--layout" => sc.units = parse_layout(&value()),
|
||||
"--raid" => raids.push(parse_raid(&value())),
|
||||
"--terrain" => sc.terrain = parse_terrain(&value()),
|
||||
"--run-on" => sc.stop_at_end = false,
|
||||
"--ew" => sc.ew = true,
|
||||
"--no-ew" => sc.ew = false,
|
||||
"--json" => json = true,
|
||||
"--list" => {
|
||||
println!("scenarios: {PRESETS:?}");
|
||||
println!("systems: {:?}", SamKind::ALL.iter().map(|k| k.key()).collect::<Vec<_>>());
|
||||
println!("drones: {:?}", DroneKind::ALL.iter().map(|k| k.spec().key).collect::<Vec<_>>());
|
||||
return;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("{}", include_str!("main.rs").lines().take_while(|l| l.starts_with("//")).map(|l| l.trim_start_matches("//!").trim_start_matches("//").to_string() + "\n").collect::<String>());
|
||||
return;
|
||||
}
|
||||
other => die(&format!("unknown option `{other}` (try --help)")),
|
||||
}
|
||||
}
|
||||
if !raids.is_empty() {
|
||||
sc.raids = raids;
|
||||
}
|
||||
let r = run(&sc);
|
||||
if json {
|
||||
println!("{}", r.json());
|
||||
} else {
|
||||
print!("{}", r.text());
|
||||
}
|
||||
let problems = r.problems();
|
||||
if !problems.is_empty() {
|
||||
eprintln!("PROBLEMS: {}", problems.join("; "));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,26 @@ fn screen_radius(world: vec3<f32>, radius: f32) -> f32 {
|
||||
return length((b.xy - a.xy) * frame.resolution.xy);
|
||||
}
|
||||
|
||||
// The clip-space position of a world point (what `project` starts from).
|
||||
fn clip_of(world: vec3<f32>) -> vec4<f32> {
|
||||
return frame.view_proj * vec4<f32>(world - frame.camera.xyz, 1.0);
|
||||
}
|
||||
|
||||
// A clip position on the screen in uv.
|
||||
fn clip_uv(c: vec4<f32>) -> vec2<f32> {
|
||||
let ndc = c.xy / max(abs(c.w), 1e-5);
|
||||
return vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
||||
}
|
||||
|
||||
// `screen_radius` for a point whose clip position is known (the projection is linear,
|
||||
// so the offset to the right is added in clip space): saves two projections and a matrix
|
||||
// product for every effect at every pixel.
|
||||
fn screen_radius_clip(c0: vec4<f32>, radius: f32) -> f32 {
|
||||
let right = frame.view_inverse[0].xyz;
|
||||
let cb = c0 + frame.view_proj * vec4<f32>(right * radius, 0.0);
|
||||
return length((clip_uv(cb) - clip_uv(c0)) * frame.resolution.xy);
|
||||
}
|
||||
|
||||
fn now() -> f32 {
|
||||
return frame.params[0].x + frame.time.y;
|
||||
}
|
||||
@@ -86,22 +106,27 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
||||
let age = t_now - b.x;
|
||||
if age < 0.0 { continue; }
|
||||
// Past both windows (the ring's second, the haze over a hot ball) it bends nothing.
|
||||
let heat = 1.0 - smoothstep(0.0, 0.5, age / b.y);
|
||||
if age >= 24.0 && heat <= 0.0 { continue; }
|
||||
let centre = a.xyz;
|
||||
let p = project(centre);
|
||||
if p.z <= 0.5 { continue; }
|
||||
let to = px - p.xy * frame.resolution.xy;
|
||||
let c0 = clip_of(centre);
|
||||
if c0.w <= 0.5 { continue; }
|
||||
let to = px - clip_uv(c0) * frame.resolution.xy;
|
||||
let dist = length(to);
|
||||
// The shock: a ring racing out, fading in a second.
|
||||
if age < 24.0 {
|
||||
let ring = screen_radius(centre, age * 3.2 * (0.6 + a.w * 0.1));
|
||||
let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1));
|
||||
let width = 6.0 + ring * 0.08;
|
||||
let x = (dist - ring) / width;
|
||||
let strength = (1.0 - age / 24.0) * min(a.w / 4.0, 2.0) * 10.0;
|
||||
offset += normalize(to + vec2<f32>(1e-4)) * exp(-x * x) * x * strength * frame.resolution.zw;
|
||||
// exp(-x * x) * x is below 1e-8 past six widths.
|
||||
if abs(x) < 6.0 {
|
||||
let strength = (1.0 - age / 24.0) * min(a.w / 4.0, 2.0) * 10.0;
|
||||
offset += normalize(to + vec2<f32>(1e-4)) * exp(-x * x) * x * strength * frame.resolution.zw;
|
||||
}
|
||||
}
|
||||
// Heat haze over a hot fireball.
|
||||
let heat = 1.0 - smoothstep(0.0, 0.5, age / b.y);
|
||||
let r = screen_radius(centre, a.w * 2.0);
|
||||
let r = screen_radius_clip(c0, a.w * 2.0);
|
||||
if heat > 0.0 && dist < r {
|
||||
let n = noise3(vec3<f32>(px * 0.04, t_now * 0.35)) - 0.5;
|
||||
offset += vec2<f32>(n, -abs(n)) * heat * (1.0 - dist / r) * 6.0 * frame.resolution.zw;
|
||||
@@ -149,8 +174,23 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
let age = t_now - b.x;
|
||||
if age < 0.0 { continue; }
|
||||
let life = b.y;
|
||||
// Burnt out: no density and no fire light.
|
||||
if age >= life { continue; }
|
||||
let k = clamp(age / life, 0.0, 1.0);
|
||||
let size = a.w;
|
||||
// The ball and its smoke stay inside 4.5 sizes of the blast (radius 2.28 sizes, risen
|
||||
// 2.2 sizes): rays that miss that sphere cannot reach the volume. Only the
|
||||
// pixels' own direction is needed, no volume maths.
|
||||
let rel = a.xyz - frame.camera.xyz;
|
||||
let reach = size * 4.5 + 0.1;
|
||||
let cc = dot(rel, rel);
|
||||
let along = dot(rel, dir);
|
||||
if cc > reach * reach && (along < 0.0 || cc - along * along > reach * reach) {
|
||||
// Out of reach of the volume; the fire's glow still counts.
|
||||
let h0 = 1.0 - smoothstep(0.05, 0.55, k);
|
||||
glow += vec3<f32>(1.0, 0.5, 0.15) * h0 * h0 * size * 0.05 / (1.0 + length(rel + vec3<f32>(0.0, k * k * size * 2.2, 0.0)) * 0.03);
|
||||
continue;
|
||||
}
|
||||
let seed = vec3<f32>(a.x * 0.37, a.y * 0.11, a.z * 0.53);
|
||||
// The ball swells fast, then rises and spreads as smoke.
|
||||
let radius = size * (0.3 + 0.9 * (1.0 - exp(-age / 3.0))) * (1.0 + k * 0.9);
|
||||
@@ -195,13 +235,20 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
||||
let age = t_now - b.x;
|
||||
if age < 0.0 || age > 10.0 { continue; }
|
||||
let p = project(a.xyz);
|
||||
if p.z <= 0.5 { continue; }
|
||||
let visible = select(0.25, 1.0, scene_distance(p.xy) > p.z - a.w);
|
||||
let c0 = clip_of(a.xyz);
|
||||
if c0.w <= 0.5 { continue; }
|
||||
let p = vec3<f32>(clip_uv(c0), c0.w);
|
||||
// Ray distance on both sides: `p.z` is the depth along the view axis, which is shorter
|
||||
// than the distance along the ray towards the screen edge.
|
||||
let dist = length(a.xyz - frame.camera.xyz);
|
||||
let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w);
|
||||
let f = exp(-age * 0.7) * min(a.w / 4.0, 2.5) * visible;
|
||||
let r = screen_radius(a.xyz, a.w * 2.5) + 20.0;
|
||||
let r = screen_radius_clip(c0, a.w * 2.5) + 20.0;
|
||||
let dd = length(px - p.xy * frame.resolution.xy) / r;
|
||||
color += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25);
|
||||
// The bloom's tail is below 5e-4 (under 8 bits) past nine radii.
|
||||
if dd < 9.0 {
|
||||
color += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25);
|
||||
}
|
||||
color += vec3<f32>(1.0, 0.8, 0.55) * f * 0.12 / (1.0 + p.z * 0.01);
|
||||
}
|
||||
|
||||
@@ -209,11 +256,15 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
for (var j = 0; j < lights; j++) {
|
||||
let a = frame.params[LIGHT_BASE + 2 * j];
|
||||
let b = frame.params[LIGHT_BASE + 2 * j + 1];
|
||||
let p = project(a.xyz);
|
||||
if p.z <= 0.3 { continue; }
|
||||
if scene_distance(p.xy) < p.z - a.w * 1.5 { continue; }
|
||||
let r = screen_radius(a.xyz, a.w) + 3.0;
|
||||
let dd = length(px - p.xy * frame.resolution.xy) / r;
|
||||
let c0 = clip_of(a.xyz);
|
||||
if c0.w <= 0.3 { continue; }
|
||||
let p = clip_uv(c0);
|
||||
let r = screen_radius_clip(c0, a.w) + 3.0;
|
||||
let dd = length(px - p * frame.resolution.xy) / r;
|
||||
// Far from the glow (intensity at most 2): its tail is below 5e-4, under 8 bits.
|
||||
// This is checked before the depth lookup, which most pixels then never make.
|
||||
if dd > 6.0 { continue; }
|
||||
if scene_distance(p) < length(a.xyz - frame.camera.xyz) - a.w * 1.5 { continue; }
|
||||
let flicker = 0.85 + 0.15 * noise3(vec3<f32>(a.xyz * 0.7 + vec3<f32>(t_now * 1.3)));
|
||||
color += b.rgb * b.w * flicker * (exp(-dd * dd * 4.0) * 1.2 + exp(-dd * 1.2) * 0.18);
|
||||
}
|
||||
|
||||
+142
-25
@@ -16,7 +16,8 @@ mod monitor;
|
||||
mod panel;
|
||||
mod radar;
|
||||
mod replay;
|
||||
mod sim;
|
||||
pub mod sim;
|
||||
mod scenario;
|
||||
mod wave;
|
||||
use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim};
|
||||
|
||||
@@ -56,6 +57,9 @@ struct AirDef {
|
||||
sim: Sim,
|
||||
ground: Ground,
|
||||
dirty: bool,
|
||||
/// Wall-clock-free pacing of autosaves: the tick before which none is written (at most one a
|
||||
/// second while guns fire, and a pause after a failed write).
|
||||
next_save: u64,
|
||||
hud_shown: bool,
|
||||
/// The system the monitor shows (K cycles), none: hidden.
|
||||
monitor: Option<usize>,
|
||||
@@ -82,8 +86,17 @@ struct AirDef {
|
||||
menu: Option<Page>,
|
||||
/// Where the player is, for the sounds' delay and far variants.
|
||||
listener: DVec3,
|
||||
/// Sounds on their way to the player at the speed of sound: (tick, event, at, pitch).
|
||||
pending: Vec<(u64, String, DVec3, f32)>,
|
||||
/// Sounds on their way to the player at the speed of sound.
|
||||
pending: Vec<PendingSound>,
|
||||
}
|
||||
|
||||
/// A sound heard later: when, what, where, and how loud and high it was.
|
||||
struct PendingSound {
|
||||
due: u64,
|
||||
name: String,
|
||||
at: DVec3,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
}
|
||||
|
||||
/// Blocks a tick the sound travels (343 m/s).
|
||||
@@ -307,7 +320,8 @@ impl AirDef {
|
||||
let size = b.size;
|
||||
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
|
||||
let flash = (-age * 0.35).exp() * 4.0;
|
||||
let id = 0x6100_0000 ^ (b.start as u32).wrapping_mul(31) ^ (b.at.x as i32 as u32).wrapping_mul(7);
|
||||
// Its own id range: the models' and the systems' ids are small numbers.
|
||||
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
|
||||
add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
|
||||
}
|
||||
for s in &self.sim.sams {
|
||||
@@ -317,6 +331,7 @@ impl AirDef {
|
||||
// A searchlight's spot where its beam meets the target.
|
||||
if s.kind == SamKind::Light
|
||||
&& let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t))
|
||||
&& self.sim.lit.contains(&t.id)
|
||||
{
|
||||
add(s.id + 14, t.pos, (0.95, 0.97, 1.0), 220.0, 30.0);
|
||||
}
|
||||
@@ -351,7 +366,14 @@ impl AirDef {
|
||||
let mut p = vec![0.0f32; 4];
|
||||
p[0] = self.sim.tick as f32;
|
||||
p[3] = if self.hd { 1.0 } else { 0.0 };
|
||||
let blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
|
||||
let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
|
||||
// The shader composites the volumes front to back with one shared transmittance, so
|
||||
// the nearest to the player goes first. This is exact for volumes that do not
|
||||
// overlap; where two intersect, no order by centre is right along every ray.
|
||||
if let Some(pl) = player() {
|
||||
let e = eye(&pl);
|
||||
blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
|
||||
}
|
||||
p[1] = blasts.len() as f32;
|
||||
for b in &blasts {
|
||||
p.extend([b.at.x as f32, b.at.y as f32, b.at.z as f32, b.size]);
|
||||
@@ -440,7 +462,7 @@ impl AirDef {
|
||||
if delay == 0 {
|
||||
render::play_sound(&name, at.x, at.y, at.z, volume, pitch);
|
||||
} else if self.pending.len() < 256 {
|
||||
self.pending.push((now + delay, name, at, pitch));
|
||||
self.pending.push(PendingSound { due: now + delay, name, at, volume, pitch });
|
||||
}
|
||||
} else {
|
||||
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
|
||||
@@ -448,9 +470,9 @@ impl AirDef {
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.pending.len() {
|
||||
if self.pending[i].0 <= now {
|
||||
let (_, name, at, pitch) = self.pending.swap_remove(i);
|
||||
render::play_sound(&name, at.x, at.y, at.z, 1.0, pitch);
|
||||
if self.pending[i].due <= now {
|
||||
let s = self.pending.swap_remove(i);
|
||||
render::play_sound(&s.name, s.at.x, s.at.y, s.at.z, s.volume, s.pitch);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
@@ -484,25 +506,64 @@ impl AirDef {
|
||||
|
||||
// ------------------------------------------------ saving
|
||||
|
||||
fn save(&mut self) {
|
||||
/// The state as text, lines of words: the autosave's format, and the body of a scenario.
|
||||
/// `fresh`: every system and object whole, magazines full (a scenario to replay from the start).
|
||||
fn state_text(&self, fresh: bool, view: bool) -> String {
|
||||
let mut text = String::new();
|
||||
for s in &self.sim.sams {
|
||||
text += &format!("sam {} {} {} {} {} {} {} {}\n", s.kind.key(), s.pos.x, s.pos.y, s.pos.z, s.heading, s.roles, s.health, s.cargo);
|
||||
// Then its ammunition (missiles, bursts, out and waiting for a truck, reload ticks
|
||||
// left) and where it was driving, if anywhere.
|
||||
let dest = s.dest.filter(|_| !fresh).map_or("- - -".to_string(), |d| format!("{} {} {}", d.x, d.y, d.z));
|
||||
let new = Sam::new(0, s.kind, s.pos, s.heading);
|
||||
let (health, cargo, missiles, bursts, empty, reloading) = if fresh {
|
||||
(new.health, new.cargo, new.missiles, new.bursts, false, 0)
|
||||
} else {
|
||||
(s.health, s.cargo, s.missiles, s.bursts, s.empty, s.reloading)
|
||||
};
|
||||
text += &format!(
|
||||
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest}\n",
|
||||
s.kind.key(),
|
||||
s.pos.x,
|
||||
s.pos.y,
|
||||
s.pos.z,
|
||||
s.heading,
|
||||
s.roles,
|
||||
health,
|
||||
cargo,
|
||||
missiles,
|
||||
bursts,
|
||||
u8::from(empty),
|
||||
reloading,
|
||||
);
|
||||
}
|
||||
for (i, t) in self.sim.targets.iter().enumerate() {
|
||||
let h = self.sim.target_health.get(i).copied().unwrap_or(100.0);
|
||||
let h = if fresh { 100.0 } else { self.sim.target_health.get(i).copied().unwrap_or(100.0) };
|
||||
text += &format!("target {} {} {} {h}\n", t.x, t.y, t.z);
|
||||
}
|
||||
text += &format!("damage {}\n", self.sim.damage);
|
||||
text += &format!("far {}\n", self.far);
|
||||
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
|
||||
if view {
|
||||
text += &format!("far {}\n", self.far);
|
||||
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
|
||||
}
|
||||
for (kind, roles) in &self.sim.roles {
|
||||
text += &format!("roles {} {}\n", kind.key(), roles);
|
||||
}
|
||||
if let Err(e) = storage::set(&self.state_key(), text.as_bytes()) {
|
||||
horizon_mod_sdk::warn(&format!("could not save: {e}"));
|
||||
text
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
let text = self.state_text(false, true);
|
||||
// Clean only once written: a failed write is tried again after a pause.
|
||||
match storage::set(&self.state_key(), text.as_bytes()) {
|
||||
Ok(()) => {
|
||||
self.dirty = false;
|
||||
self.next_save = self.sim.tick + 20;
|
||||
}
|
||||
Err(e) => {
|
||||
horizon_mod_sdk::warn(&format!("could not save: {e}"));
|
||||
self.next_save = self.sim.tick + 200;
|
||||
}
|
||||
}
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
fn load(&mut self) {
|
||||
@@ -511,7 +572,12 @@ impl AirDef {
|
||||
|
||||
fn load_from(&mut self, key: &str) {
|
||||
let Some(bytes) = storage::get(key) else { return };
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
self.apply_state(&String::from_utf8_lossy(&bytes));
|
||||
}
|
||||
|
||||
/// Adds the state in `text` (`state_text`'s lines; unknown lines are skipped, so older and
|
||||
/// newer files read alike).
|
||||
fn apply_state(&mut self, text: &str) {
|
||||
for line in text.lines() {
|
||||
let w: Vec<&str> = line.split_whitespace().collect();
|
||||
let num = |i: usize| w.get(i).and_then(|v| v.parse::<f64>().ok());
|
||||
@@ -531,7 +597,22 @@ impl AirDef {
|
||||
if let Some(c) = num(8) {
|
||||
sam.cargo = c as u32;
|
||||
}
|
||||
// Older saves have no ammunition: full, as before.
|
||||
if let (Some(m), Some(b)) = (num(9), num(10)) {
|
||||
sam.missiles = (m as u32).min(sam.missiles);
|
||||
sam.bursts = (b as u32).min(sam.bursts);
|
||||
sam.empty = num(11) == Some(1.0);
|
||||
sam.reloading = num(12).map_or(0, |r| r as u32);
|
||||
}
|
||||
let dest = match (num(13), num(14), num(15)) {
|
||||
(Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)),
|
||||
_ => None,
|
||||
};
|
||||
self.sim.sams.push(sam);
|
||||
if let Some(to) = dest {
|
||||
let i = self.sim.sams.len() - 1;
|
||||
self.sim.order_move(i, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(&"target") => {
|
||||
@@ -541,6 +622,13 @@ impl AirDef {
|
||||
}
|
||||
}
|
||||
Some(&"damage") => self.sim.damage = w.get(1) != Some(&"false"),
|
||||
// A scenario's: the random generator's state, and the wave menu's raid.
|
||||
Some(&"seed") => {
|
||||
if let Some(v) = w.get(1).and_then(|v| v.parse::<u64>().ok()) {
|
||||
self.sim.rng = sim::Rng::new(v);
|
||||
}
|
||||
}
|
||||
Some(&"wave") => self.wave.parse(&w[1..]),
|
||||
Some(&"far") => self.far = w.get(1) != Some(&"false"),
|
||||
Some(&"effects") => {
|
||||
let shader = w.get(1) != Some(&"false");
|
||||
@@ -602,11 +690,18 @@ impl AirDef {
|
||||
let shader = self.sim.shader;
|
||||
let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37);
|
||||
self.sim = Sim::new(seed);
|
||||
self.next_save = 0;
|
||||
self.sim.shader = shader;
|
||||
self.ground = Ground::default();
|
||||
self.monitor = None;
|
||||
self.grid = false;
|
||||
self.grid_ids.clear();
|
||||
// The last world's transient state: its sounds were due on its own
|
||||
// clock, its picked unit may have another meaning in this one, and
|
||||
// its models would stay until the next full model list.
|
||||
self.pending.clear();
|
||||
self.moving = None;
|
||||
render::set_models(&[]);
|
||||
self.far = true;
|
||||
self.world = Some(world);
|
||||
self.load();
|
||||
@@ -641,7 +736,7 @@ impl AirDef {
|
||||
}
|
||||
_ => replay::Mode::Map,
|
||||
};
|
||||
self.replay = Some(replay::Player::new(n - 1, mode, &self.sim));
|
||||
self.replay = Some(replay::Player::new(rec.number, mode, &self.sim));
|
||||
self.grid = false;
|
||||
input::claim_wheel(true);
|
||||
Ok(())
|
||||
@@ -843,7 +938,7 @@ impl AirDef {
|
||||
}
|
||||
}
|
||||
|
||||
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
|
||||
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
|
||||
|
||||
impl Mod for AirDef {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
@@ -852,6 +947,7 @@ impl Mod for AirDef {
|
||||
sim: Sim::new(seed),
|
||||
ground: Ground::default(),
|
||||
dirty: false,
|
||||
next_save: 0,
|
||||
hud_shown: false,
|
||||
monitor: None,
|
||||
monitor_shown: false,
|
||||
@@ -914,7 +1010,7 @@ impl Mod for AirDef {
|
||||
_ => replay::Control::Pause,
|
||||
};
|
||||
if let Some(r) = &mut self.replay
|
||||
&& let Some(rec) = self.sim.replays.get(r.rec)
|
||||
&& let Some(rec) = self.sim.replay(r.rec)
|
||||
{
|
||||
r.control(c, rec);
|
||||
}
|
||||
@@ -931,7 +1027,7 @@ impl Mod for AirDef {
|
||||
}
|
||||
Event::Wheel(w) => {
|
||||
if let Some(r) = &mut self.replay
|
||||
&& let Some(rec) = self.sim.replays.get(r.rec)
|
||||
&& let Some(rec) = self.sim.replay(r.rec)
|
||||
{
|
||||
r.wheel(w, rec);
|
||||
}
|
||||
@@ -993,19 +1089,25 @@ impl Mod for AirDef {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Damage is saved too: a destroyed system stays destroyed.
|
||||
// Damage is saved too: a destroyed system stays destroyed. So are where the units stand
|
||||
// (or drive to: not every step of the way), their ammunition and the depots' stock.
|
||||
let mark = self
|
||||
.sim
|
||||
.sams
|
||||
.iter()
|
||||
.map(|s| (s.health * 1000.0) as u64)
|
||||
.map(|s| {
|
||||
let at = s.dest.unwrap_or(s.pos);
|
||||
let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42);
|
||||
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;
|
||||
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7)
|
||||
})
|
||||
.chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64))
|
||||
.fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h));
|
||||
if mark != self.health_mark {
|
||||
self.health_mark = mark;
|
||||
self.dirty = true;
|
||||
}
|
||||
if self.dirty {
|
||||
if self.dirty && self.sim.tick >= self.next_save {
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
@@ -1212,6 +1314,21 @@ impl Mod for AirDef {
|
||||
}
|
||||
None
|
||||
}
|
||||
["scenario", rest @ ..] => Some(self.scenario_command(rest)?),
|
||||
["why", rest @ ..] => {
|
||||
// The system n of the summary, else the monitored one.
|
||||
let i = match rest.first() {
|
||||
Some(n) => n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).ok_or("/airdef why <номер>")?,
|
||||
None => self.monitor.ok_or("/airdef why <номер> (или откройте монитор K)")?,
|
||||
};
|
||||
if i >= self.sim.sams.len() {
|
||||
return Err("Нет такой техники".into());
|
||||
}
|
||||
for c in self.sim.why(i) {
|
||||
say(&format!("{} {}", if c.ok { "+" } else { "-" }, c.text));
|
||||
}
|
||||
None
|
||||
}
|
||||
["raid"] => {
|
||||
self.launch_wave(&mut out)?;
|
||||
None
|
||||
|
||||
@@ -25,11 +25,22 @@ fn fit(t: String, w: f32) -> String {
|
||||
if render::text_width(&t) <= w {
|
||||
return t;
|
||||
}
|
||||
let mut cut: String = t;
|
||||
while !cut.is_empty() && render::text_width(&format!("{cut}…")) > w {
|
||||
cut.pop();
|
||||
// The width grows with the prefix: bisect over its char starts, so a long
|
||||
// label costs a few host calls rather than one per dropped char.
|
||||
let starts: Vec<usize> = t.char_indices().map(|(i, _)| i).collect();
|
||||
let (mut lo, mut hi) = (0, starts.len());
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if render::text_width(&format!("{}…", &t[..starts[mid]])) <= w {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
format!("{cut}…")
|
||||
if lo == 0 {
|
||||
return "…".into();
|
||||
}
|
||||
format!("{}…", &t[..starts[lo - 1]])
|
||||
}
|
||||
|
||||
fn argb(rgb: u32, a: u32) -> u32 {
|
||||
@@ -101,7 +112,13 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
s.push(quad(p(a0, r - 0.4), p(a0, r + 0.4), p(a1, r + 0.4), p(a1, r - 0.4), 0x9040FF60));
|
||||
}
|
||||
}
|
||||
let reach = sam.kind.range();
|
||||
// A system that only fires (no radar) has no reach: a radar's sensor
|
||||
// range stands in, and nothing is divided by zero.
|
||||
let reach = match sam.kind.range() {
|
||||
r if r > 0.0 => r,
|
||||
_ => spec.sensor,
|
||||
};
|
||||
let scale = if reach > 0.0 { rr as f64 / (reach * 1.25) } else { 0.0 };
|
||||
let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU;
|
||||
for k in 0..6 {
|
||||
let a1 = sweep - k as f32 * 0.09;
|
||||
@@ -115,11 +132,11 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
for d in sim.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)) {
|
||||
let rel = d.pos - sam.pos;
|
||||
let flat = (rel.x * rel.x + rel.z * rel.z).sqrt();
|
||||
if flat > reach * 1.25 {
|
||||
if reach <= 0.0 || flat > reach * 1.25 {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
let k = (rr as f64 / (reach * 1.25)) as f32;
|
||||
let k = scale as f32;
|
||||
let (bx, by) = (rx + rel.x as f32 * k, ry + rel.z as f32 * k);
|
||||
let allowed = sam.roles & d.kind.spec().class.bit() != 0;
|
||||
let color = if Some(d.id) == target.map(|t| t.id) {
|
||||
@@ -213,6 +230,13 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
|
||||
let mut s = Vec::new();
|
||||
cell(sim, index, 4.0, 4.0, 300.0, 150.0, &mut s);
|
||||
// Why it fires or doesn't: the conditions the simulation decides by, worked out now for this one.
|
||||
let checks = sim.why(index);
|
||||
s.push(fill(4.0, 156.0, 300.0, 4.0 + 10.0 * checks.len() as f32, 0xA0080C0A));
|
||||
for (k, c) in checks.iter().enumerate() {
|
||||
let color = if c.ok { 0xFF80FF90 } else { 0xFFFF9060 };
|
||||
s.push(text(fit(format!("{} {}", if c.ok { "+" } else { "-" }, c.text), 294.0), 7.0, 158.0 + 10.0 * k as f32, color));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -10,6 +10,8 @@ const CYAN: u32 = 0x9FE0FF;
|
||||
const GREY: u32 = 0xB0B0B0;
|
||||
const YELLOW: u32 = 0xFFE070;
|
||||
const GREEN: u32 = 0x80FF90;
|
||||
/// The most lines the summary holds (the HUD's room).
|
||||
const MAX_LINES: usize = 16;
|
||||
|
||||
/// Metres (a block is a metre) as a short distance.
|
||||
pub fn distance(m: f64) -> String {
|
||||
@@ -77,7 +79,15 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
if !sim.missiles.is_empty() {
|
||||
lines.push(line(format!("Перехватчиков в полёте: {}", sim.missiles.len()), YELLOW));
|
||||
}
|
||||
for sam in sim.sams.iter().take(8) {
|
||||
// The score is the last line and always shown: the unit list gets what
|
||||
// room is left, and cuts its own tail.
|
||||
let room = (MAX_LINES - 1).saturating_sub(lines.len());
|
||||
let total = sim.sams.len();
|
||||
let mut shown = total.min(8);
|
||||
if shown + usize::from(total > 8) > room {
|
||||
shown = room.saturating_sub(1);
|
||||
}
|
||||
for sam in sim.sams.iter().take(shown) {
|
||||
let mut text = if sam.kind.armed() {
|
||||
format!("{} [{}]", sam.status(), crate::sim::roles_text(sam.roles))
|
||||
} else {
|
||||
@@ -91,15 +101,16 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
}
|
||||
lines.push(line(text, CYAN));
|
||||
}
|
||||
if sim.sams.len() > 8 {
|
||||
lines.push(line(format!("…и ещё {} комплексов", sim.sams.len() - 8), CYAN));
|
||||
if total > shown && room > 0 {
|
||||
lines.push(line(format!("…и ещё {} комплексов", total - shown), CYAN));
|
||||
}
|
||||
// Room for the score whatever came before.
|
||||
lines.truncate(MAX_LINES - 1);
|
||||
let real = sim.launched.saturating_sub(0);
|
||||
let rate = if real > 0 { sim.kills as f64 * 100.0 / real as f64 } else { 0.0 };
|
||||
lines.push(line(
|
||||
format!("Сбито {} из {} ({rate:.0}%) · прорвалось {}", sim.kills, sim.launched, sim.hits),
|
||||
GREY,
|
||||
));
|
||||
lines.truncate(16);
|
||||
lines
|
||||
}
|
||||
|
||||
+73
-40
@@ -5,7 +5,8 @@
|
||||
//! behind the drone, then behind the interceptor or from the side — slowing
|
||||
//! time round the moment itself.
|
||||
use crate::panel::distance;
|
||||
use crate::sim::record::{EventKind, Recording, What};
|
||||
use crate::sim::record::{EventKind, Recording, STEP, What};
|
||||
use std::cell::Cell;
|
||||
use crate::sim::{Class, DroneKind, Out, Sim};
|
||||
use glam::DVec3;
|
||||
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape};
|
||||
@@ -31,8 +32,8 @@ pub struct Clip {
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
/// Which of the sim's recordings.
|
||||
pub rec: usize,
|
||||
/// Which raid's recording (its number: the list drops the oldest as new ones come).
|
||||
pub rec: u32,
|
||||
pub mode: Mode,
|
||||
t: f64,
|
||||
speed: f64,
|
||||
@@ -55,6 +56,8 @@ pub struct Player {
|
||||
/// Ticks the controls stay on screen.
|
||||
help: u32,
|
||||
pub done: bool,
|
||||
/// The map's area, worked out on the first map frame.
|
||||
area: Cell<Option<[f64; 4]>>,
|
||||
}
|
||||
|
||||
/// What the replay's keys do.
|
||||
@@ -188,7 +191,7 @@ pub fn reel(rec: &Recording) -> Vec<Clip> {
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new(rec: usize, mode: Mode, sim: &Sim) -> Self {
|
||||
pub fn new(rec: u32, mode: Mode, sim: &Sim) -> Self {
|
||||
let start = match &mode {
|
||||
Mode::Clips { list, .. } => list.first().map_or(0.0, |c| c.from),
|
||||
_ => 0.0,
|
||||
@@ -201,6 +204,7 @@ impl Player {
|
||||
};
|
||||
let _ = sim;
|
||||
Player {
|
||||
area: Cell::new(None),
|
||||
rec,
|
||||
mode,
|
||||
t: start,
|
||||
@@ -291,7 +295,7 @@ impl Player {
|
||||
|
||||
/// One tick: time runs, the ghosts fly, the director films. Models and sounds go to `out`.
|
||||
pub fn tick(&mut self, sim: &Sim, listener: DVec3, out: &mut Out) {
|
||||
let Some(rec) = sim.replays.get(self.rec) else {
|
||||
let Some(rec) = sim.replay(self.rec) else {
|
||||
self.done = true;
|
||||
return;
|
||||
};
|
||||
@@ -491,7 +495,7 @@ impl Player {
|
||||
|
||||
/// The replay's screen: the film with its titles, or the map.
|
||||
pub fn hud(&self, sim: &Sim, (gw, gh): (f32, f32)) -> Vec<Shape> {
|
||||
let Some(rec) = sim.replays.get(self.rec) else { return Vec::new() };
|
||||
let Some(rec) = sim.replay(self.rec) else { return Vec::new() };
|
||||
if matches!(self.mode, Mode::Map) {
|
||||
return self.map(rec, gw, gh);
|
||||
}
|
||||
@@ -581,34 +585,16 @@ impl Player {
|
||||
fn map(&self, rec: &Recording, gw: f32, gh: f32) -> Vec<Shape> {
|
||||
let mut s = vec![fill(0.0, 0.0, gw, gh, 0xF0060A08)];
|
||||
let o = rec.origin;
|
||||
// The area: everything that flew, but not far beyond the systems.
|
||||
let mut lo = (f64::MAX, f64::MAX);
|
||||
let mut hi = (f64::MIN, f64::MIN);
|
||||
let mut grow = |p: DVec3| {
|
||||
lo = (lo.0.min(p.x), lo.1.min(p.z));
|
||||
hi = (hi.0.max(p.x), hi.1.max(p.z));
|
||||
// The area is the same every tick: worked out once for the recording.
|
||||
let [l0, l1, h0, h1] = match self.area.get() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let a = area(rec, o);
|
||||
self.area.set(Some(a));
|
||||
a
|
||||
}
|
||||
};
|
||||
for tr in &rec.tracks {
|
||||
if let What::Drone(k) = tr.what
|
||||
&& k.spec().ballistic.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for p in tr.path(o, 20, rec.len as f64) {
|
||||
grow(p);
|
||||
}
|
||||
}
|
||||
for sam in &rec.sams {
|
||||
grow(sam.2 - DVec3::splat(60.0));
|
||||
grow(sam.2 + DVec3::splat(60.0));
|
||||
}
|
||||
for p in &rec.objects {
|
||||
grow(*p);
|
||||
}
|
||||
if lo.0 > hi.0 {
|
||||
lo = (o.x - 200.0, o.z - 200.0);
|
||||
hi = (o.x + 200.0, o.z + 200.0);
|
||||
}
|
||||
let (lo, hi) = ((l0, l1), (h0, h1));
|
||||
let (top, bottom) = (20.0f32, 34.0f32);
|
||||
let (aw, ah) = (gw - 16.0, gh - top - bottom);
|
||||
let k = (aw as f64 / (hi.0 - lo.0).max(1.0)).min(ah as f64 / (hi.1 - lo.1).max(1.0));
|
||||
@@ -638,7 +624,9 @@ impl Player {
|
||||
let (x, y) = at(*p);
|
||||
s.push(fill(x - 3.0, y - 3.0, 6.0, 6.0, 0xFFFFFFFF));
|
||||
}
|
||||
// Tracks up to now: a budget of segments shared out.
|
||||
// Tracks up to now: a budget of segments shared out. The stride is
|
||||
// worked out from the points there are, so only the segments drawn
|
||||
// are ever copied.
|
||||
let n = rec.tracks.len().max(1);
|
||||
let per = (640 / n).clamp(2, 14);
|
||||
for tr in &rec.tracks {
|
||||
@@ -647,16 +635,21 @@ impl Player {
|
||||
What::Drone(_) => (0x90FF4040, 0xFFFF6060),
|
||||
What::Missile(..) => (0x90FFD040, 0xFFFFE070),
|
||||
};
|
||||
let pts = tr.path(o, 1, self.t);
|
||||
let upto = ((self.t - tr.first as f64) / STEP as f64).floor();
|
||||
if upto < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let last = ((tr.last() - tr.first) / STEP) as f64;
|
||||
let count = upto.min(last) as usize + 1;
|
||||
if count < 2 {
|
||||
continue;
|
||||
}
|
||||
let pts = tr.path(o, count.div_ceil(per).max(1), self.t);
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let every = pts.len().div_ceil(per).max(1);
|
||||
let mut prev = at(pts[0]);
|
||||
for (i, p) in pts.iter().enumerate().skip(1) {
|
||||
if i % every != 0 && i != pts.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
for p in pts.iter().skip(1) {
|
||||
let q = at(*p);
|
||||
if inside(prev) && inside(q) {
|
||||
s.push(line(prev, q, 1.0, color));
|
||||
@@ -699,6 +692,11 @@ impl Player {
|
||||
}
|
||||
let keys = format!("{KEYS_HELP} · колесо: время");
|
||||
s.push(text(keys, gw / 2.0, gh - 12.0, 0xC0FFFFFF, true));
|
||||
// What went wrong for the air defence, as counted during the raid.
|
||||
let tally = rec.tally.text();
|
||||
if !tally.is_empty() && horizon_mod_sdk::render::text_width(&tally) < gw - 24.0 - lw {
|
||||
s.push(text(tally, 8.0, 6.0 + 11.0, 0xB0FFC080, false));
|
||||
}
|
||||
let (tx, ty, tw) = (8.0, gh - bottom + 6.0, gw - 16.0);
|
||||
s.push(fill(tx, ty, tw, 3.0, 0x60FFFFFF));
|
||||
let len = rec.len.max(1) as f32;
|
||||
@@ -718,6 +716,41 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ground the map shows, as [lo x, lo z, hi x, hi z]: everything that
|
||||
/// flew (not the ballistic falls), with the systems and objects, or round the
|
||||
/// origin when nothing did.
|
||||
fn area(rec: &Recording, o: DVec3) -> [f64; 4] {
|
||||
let mut lo = (f64::MAX, f64::MAX);
|
||||
let mut hi = (f64::MIN, f64::MIN);
|
||||
let mut grow = |p: DVec3| {
|
||||
lo = (lo.0.min(p.x), lo.1.min(p.z));
|
||||
hi = (hi.0.max(p.x), hi.1.max(p.z));
|
||||
};
|
||||
for tr in &rec.tracks {
|
||||
if let What::Drone(k) = tr.what
|
||||
&& k.spec().ballistic.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for p in tr.path(o, 20, rec.len as f64) {
|
||||
grow(p);
|
||||
}
|
||||
}
|
||||
// The systems, with some room round them.
|
||||
for sam in &rec.sams {
|
||||
grow(sam.2 - DVec3::splat(60.0));
|
||||
grow(sam.2 + DVec3::splat(60.0));
|
||||
}
|
||||
for p in &rec.objects {
|
||||
grow(*p);
|
||||
}
|
||||
if lo.0 > hi.0 {
|
||||
lo = (o.x - 200.0, o.z - 200.0);
|
||||
hi = (o.x + 200.0, o.z + 200.0);
|
||||
}
|
||||
[lo.0, lo.1, hi.0, hi.1]
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn event_clip(rec: &Recording, i: usize) -> Mode {
|
||||
Mode::Clips { list: vec![clip(rec, i)], at: 0, reel: false }
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Named scenarios: a snapshot of the layout (systems, objects), the wave menu's raid and the
|
||||
//! random generator, kept under a name and loaded back later. The body is the autosave's text
|
||||
//! (`state_text`); a header names the format's version and the world it was made in.
|
||||
use super::*;
|
||||
|
||||
/// The format this build writes and reads.
|
||||
const VERSION: u32 = 1;
|
||||
/// Names of the saved scenarios, one a line.
|
||||
const INDEX: &str = "scenarios";
|
||||
|
||||
fn key(name: &str) -> String {
|
||||
format!("scn-{name}")
|
||||
}
|
||||
|
||||
fn valid(name: &str) -> Result<String, String> {
|
||||
let n = name.to_lowercase();
|
||||
if n.is_empty() || n.len() > 32 || !n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err("Имя сценария: до 32 символов, латиница, цифры, - и _".into());
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn names() -> Vec<String> {
|
||||
storage::get(INDEX).map(|b| String::from_utf8_lossy(&b).lines().map(str::to_string).filter(|l| !l.is_empty()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
impl AirDef {
|
||||
/// The scenario as text.
|
||||
fn scenario_text(&self, name: &str, fresh: bool) -> String {
|
||||
let mut text = format!("scenario {VERSION}\nname {name}\nworld {}\n", self.state_key());
|
||||
text += &self.state_text(fresh, false);
|
||||
text += &format!("seed {}\nwave {}\n", self.sim.rng.state(), self.wave.text());
|
||||
text
|
||||
}
|
||||
|
||||
/// Replaces the world's layout with the scenario `text`; nothing changes if it is refused.
|
||||
fn apply_scenario(&mut self, text: &str, force: bool) -> Result<(), String> {
|
||||
let mut lines = text.lines();
|
||||
let version = lines
|
||||
.next()
|
||||
.and_then(|l| l.strip_prefix("scenario "))
|
||||
.ok_or("Это не сценарий (нет строки версии)")?
|
||||
.trim();
|
||||
match version.parse::<u32>() {
|
||||
Ok(VERSION) => {}
|
||||
_ => return Err(format!("Сценарий формата {version}: эта версия мода понимает только формат {VERSION}")),
|
||||
}
|
||||
let world = text.lines().find_map(|l| l.strip_prefix("world ")).unwrap_or("").trim();
|
||||
if !force && world != self.state_key() {
|
||||
return Err("Сценарий сделан в другом мире: координаты могут не подойти (загрузить всё равно: добавьте force)".into());
|
||||
}
|
||||
if self.replay.is_some() {
|
||||
self.stop_replay();
|
||||
}
|
||||
// Everything of the last layout goes: its systems with their jobs and targets, the sky,
|
||||
// the cook-offs to come; ids are made anew with the new systems.
|
||||
self.sim.clear();
|
||||
self.sim.sams.clear();
|
||||
self.sim.targets.clear();
|
||||
self.sim.target_health.clear();
|
||||
self.sim.roles.clear();
|
||||
self.sim.kills = 0;
|
||||
self.sim.hits = 0;
|
||||
self.sim.launched = 0;
|
||||
self.monitor = None;
|
||||
self.grid_ids.clear();
|
||||
self.moving = None;
|
||||
self.pending.clear();
|
||||
render::set_models(&[]);
|
||||
self.apply_state(text);
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/airdef scenario save|load|list|delete`.
|
||||
pub(crate) fn scenario_command(&mut self, args: &[&str]) -> Result<String, String> {
|
||||
match args {
|
||||
["save", name, flags @ ..] => {
|
||||
let name = valid(name)?;
|
||||
let fresh = flags.contains(&"fresh");
|
||||
let text = self.scenario_text(&name, fresh);
|
||||
storage::set(&key(&name), text.as_bytes()).map_err(|e| format!("Не удалось сохранить: {e}"))?;
|
||||
let mut all = names();
|
||||
if !all.contains(&name) {
|
||||
all.push(name.clone());
|
||||
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
|
||||
}
|
||||
Ok(format!(
|
||||
"Сценарий «{name}» сохранён: комплексов {}, объектов {}{}",
|
||||
self.sim.sams.len(),
|
||||
self.sim.targets.len(),
|
||||
if fresh { " (целые, с полным боезапасом)" } else { "" }
|
||||
))
|
||||
}
|
||||
["load", name, flags @ ..] => {
|
||||
let name = valid(name)?;
|
||||
let bytes = storage::get(&key(&name)).ok_or(format!("Нет сценария «{name}» (/airdef scenario list)"))?;
|
||||
self.apply_scenario(&String::from_utf8_lossy(&bytes), flags.contains(&"force"))?;
|
||||
Ok(format!("Сценарий «{name}» загружен: комплексов {}, объектов {}", self.sim.sams.len(), self.sim.targets.len()))
|
||||
}
|
||||
["list"] => {
|
||||
let all = names();
|
||||
if all.is_empty() {
|
||||
return Ok("Сценариев нет: /airdef scenario save <имя>".into());
|
||||
}
|
||||
Ok(format!("Сценарии: {}", all.join(", ")))
|
||||
}
|
||||
["delete", name] => {
|
||||
let name = valid(name)?;
|
||||
let mut all = names();
|
||||
if !all.contains(&name) {
|
||||
return Err(format!("Нет сценария «{name}»"));
|
||||
}
|
||||
all.retain(|n| *n != name);
|
||||
storage::delete(&key(&name));
|
||||
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
|
||||
Ok(format!("Сценарий «{name}» удалён"))
|
||||
}
|
||||
_ => Err("/airdef scenario save <имя> [fresh] | load <имя> [force] | list | delete <имя>".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,12 +179,18 @@ impl Sim {
|
||||
sam.burn = 0;
|
||||
sam.state = SamState::Search;
|
||||
sam.reloading = 0;
|
||||
sam.empty = false;
|
||||
// Rebuilt where it stands: a march under way is called off.
|
||||
sam.dest = None;
|
||||
sam.march = 0;
|
||||
sam.missiles = spec.missile.as_ref().map_or(0, |m| m.magazine);
|
||||
sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts);
|
||||
}
|
||||
for h in &mut self.target_health {
|
||||
*h = 100.0;
|
||||
}
|
||||
// The wrecks' ammunition still to cook off goes with them.
|
||||
self.later.clear();
|
||||
n
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ impl Sim {
|
||||
out
|
||||
}
|
||||
|
||||
/// During a raid: drones not yet launched go for what just gave itself away (a jamming station
|
||||
/// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station
|
||||
/// first, then systems that fired), and drones whose system target is gone pick another.
|
||||
pub(super) fn adapt(&mut self) {
|
||||
if self.tick % 40 != 0 {
|
||||
@@ -159,8 +159,10 @@ impl Sim {
|
||||
return;
|
||||
}
|
||||
let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()].1;
|
||||
// The drones sent at a new target: (waiting in the queue, index), to get a new route.
|
||||
let mut retargeted: Vec<(bool, usize)> = Vec::new();
|
||||
let mut n = 0usize;
|
||||
for (_, d) in self.queue.iter_mut() {
|
||||
for (qi, (_, d)) in self.queue.iter_mut().enumerate().filter(|(_, (_, d))| d.smart) {
|
||||
let hunter = matches!(d.kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon);
|
||||
if !hunter && !(d.kind == DroneKind::Shahed && n % 4 == 0) {
|
||||
n += 1;
|
||||
@@ -173,18 +175,80 @@ impl Sim {
|
||||
let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) };
|
||||
d.target = to;
|
||||
d.route.clear();
|
||||
retargeted.push((true, qi));
|
||||
n += 1;
|
||||
}
|
||||
// In flight: a target system destroyed — the next known one, or keep going.
|
||||
for d in self.drones.iter_mut().filter(|d| d.alive()) {
|
||||
for (di, d) in self.drones.iter_mut().enumerate().filter(|(_, d)| d.alive() && d.smart) {
|
||||
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target) < 6.0);
|
||||
if aimed_at_wreck {
|
||||
let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos)));
|
||||
if let Some((_, at)) = next {
|
||||
d.target = *at;
|
||||
d.route.clear();
|
||||
retargeted.push((false, di));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.replan_routes(&retargeted);
|
||||
}
|
||||
|
||||
/// Routes round the known defences for drones that were just sent at a new target, from where they are
|
||||
/// (or wait). One grid and one set of prices cover them all, and the work is the planner's share for a
|
||||
/// tick: those left over fly straight, as they did before there was a replan. A Lancet's strike on a
|
||||
/// unit and the ballistic missiles go straight on purpose, as in the raid's own plan.
|
||||
fn replan_routes(&mut self, jobs: &[(bool, usize)]) {
|
||||
let drone = |s: &Sim, (queued, i): (bool, usize)| if queued { s.queue[i].1.clone() } else { s.drones[i].clone() };
|
||||
let jobs: Vec<(bool, usize)> = jobs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&j| {
|
||||
let d = drone(self, j);
|
||||
let spec = d.kind.spec();
|
||||
d.smart && spec.ballistic.is_none() && !spec.decoy && d.kind != DroneKind::Lancet
|
||||
})
|
||||
.collect();
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
// One grid over all their starts and goals, with room round them to go by.
|
||||
let (mut lo, mut hi) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY));
|
||||
for &j in &jobs {
|
||||
let d = drone(self, j);
|
||||
for p in [d.pos, d.target] {
|
||||
lo = lo.min(p);
|
||||
hi = hi.max(p);
|
||||
}
|
||||
}
|
||||
let span = (hi.x - lo.x).max(hi.z - lo.z) / 2.0 + 450.0;
|
||||
let cell = raid::CELL.max(2.0 * span / raid::CELLS as f64);
|
||||
let centre = (lo + hi) / 2.0;
|
||||
let grid = raid::Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 };
|
||||
let mut prices: HashMap<DroneKind, Vec<f32>> = HashMap::new();
|
||||
let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec<DVec3>> = HashMap::new();
|
||||
let mut work = 0usize;
|
||||
for j in jobs {
|
||||
let d = drone(self, j);
|
||||
if work >= raid::WORK_PER_TICK {
|
||||
break;
|
||||
}
|
||||
let key = (d.kind, grid.index(d.pos), grid.index(d.target));
|
||||
let route = match routes.get(&key) {
|
||||
Some(r) => r.clone(),
|
||||
None => {
|
||||
if !prices.contains_key(&d.kind) {
|
||||
let (p, cost) = self.price_grid(&grid, d.kind);
|
||||
work += cost;
|
||||
prices.insert(d.kind, p);
|
||||
}
|
||||
let (r, cost) = raid::plan_route(&grid, &prices[&d.kind], d.pos, d.target);
|
||||
work += cost;
|
||||
routes.insert(key, r.clone());
|
||||
r
|
||||
}
|
||||
};
|
||||
let d = if j.0 { &mut self.queue[j.1].1 } else { &mut self.drones[j.1] };
|
||||
d.route = route;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,18 +103,23 @@ impl Sim {
|
||||
sam.yaw = slew(sam.yaw, want_yaw, 0.12);
|
||||
sam.pitch = slew(sam.pitch, want_pitch.clamp(0.05, 1.5), 0.08);
|
||||
sam.target = target.map(|t| t.0);
|
||||
if let Some((t, _)) = target {
|
||||
self.lit.push(t);
|
||||
}
|
||||
// The beam: segments widening with distance, out to the target.
|
||||
let (head, rot) = self.sams[i].mount();
|
||||
let dir = rot * DVec3::NEG_Z;
|
||||
// The target is lit once the beam is on it, not while it still swings round.
|
||||
if let Some((t, at)) = target
|
||||
&& dir.angle_between(at - head) < 0.08
|
||||
{
|
||||
self.lit.push(t);
|
||||
}
|
||||
let mut along = 1.0;
|
||||
for k in 0..10u32 {
|
||||
// Up to 14 segments (the unit's part ids 2..15), each longer than the last: one
|
||||
// reaches about 550 blocks, past the beam's reach, about 9 blocks wide out there.
|
||||
for k in 0..14u32 {
|
||||
if along >= reach {
|
||||
break;
|
||||
}
|
||||
let size = 0.8 + along * 0.025;
|
||||
let size = 0.8 + along * 0.12;
|
||||
let len = 3.0 * size;
|
||||
out.model(id + 2 + k, "airdef:beam", head + dir * (along + len / 2.0), rot, size, true);
|
||||
along += len;
|
||||
|
||||
@@ -12,9 +12,11 @@ const HANDOVER: f64 = 10.0;
|
||||
const TRUCK_LOAD: u32 = 2;
|
||||
|
||||
impl Sim {
|
||||
/// Whether ammunition is counted: a depot stands (without one, magazines refill as before).
|
||||
/// Whether ammunition is counted: a depot was set up (without one, magazines refill as
|
||||
/// before). A destroyed depot still counts: losing the last one leaves the systems short,
|
||||
/// not supplied from nowhere again.
|
||||
pub fn logistics(&self) -> bool {
|
||||
self.sams.iter().any(|s| s.kind == SamKind::Depot && s.alive())
|
||||
self.sams.iter().any(|s| s.kind == SamKind::Depot)
|
||||
}
|
||||
|
||||
/// Reloads from depots near the systems, and the trucks' rounds.
|
||||
@@ -41,6 +43,10 @@ impl Sim {
|
||||
self.reload_now(i, out);
|
||||
}
|
||||
}
|
||||
// A destroyed truck drops its job, for another to take.
|
||||
for s in self.sams.iter_mut().filter(|s| s.kind == SamKind::Tzm && !s.alive()) {
|
||||
s.job = None;
|
||||
}
|
||||
// The trucks: a job each, when idle.
|
||||
for t in 0..self.sams.len() {
|
||||
let truck = &self.sams[t];
|
||||
@@ -64,13 +70,17 @@ impl Sim {
|
||||
}
|
||||
self.sams[t].job = None;
|
||||
} else if self.sams[j].alive() && (self.sams[j].empty || self.sams[j].kind == SamKind::Depot) {
|
||||
continue; // still on its way (a new order follows when it arrives)
|
||||
// Stopped short of it (it drove off meanwhile): on to where it stands now.
|
||||
let to = self.sams[j].pos;
|
||||
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
|
||||
self.order_move(t, to + side);
|
||||
continue;
|
||||
} else {
|
||||
self.sams[t].job = None;
|
||||
}
|
||||
}
|
||||
// A new job: the nearest waiting system no other truck is driving to, or a depot to load at.
|
||||
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm).filter_map(|s| s.job).collect();
|
||||
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()).filter_map(|s| s.job).collect();
|
||||
let next = if self.sams[t].cargo > 0 {
|
||||
self.sams
|
||||
.iter()
|
||||
|
||||
+233
-90
@@ -18,6 +18,9 @@ mod lights;
|
||||
mod logistics;
|
||||
mod orders;
|
||||
mod units;
|
||||
mod why;
|
||||
#[cfg(test)]
|
||||
mod regress;
|
||||
pub use ew::EW_RADIUS;
|
||||
pub use units::{Family, SamKind};
|
||||
use units::Muzzle;
|
||||
@@ -52,6 +55,8 @@ pub struct Particle {
|
||||
/// A blast the post-processing shader draws as a fireball, flash and shock.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Blast {
|
||||
/// Never reused while the simulation lives: the HD light keeps its identity through the blast's life.
|
||||
pub id: u32,
|
||||
pub at: DVec3,
|
||||
/// Fireball radius in blocks.
|
||||
pub size: f32,
|
||||
@@ -108,6 +113,10 @@ impl Rng {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self(seed | 1)
|
||||
}
|
||||
/// The generator's state: `new(state())` continues the same sequence.
|
||||
pub fn state(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
pub fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
@@ -161,6 +170,33 @@ fn slew(from: f64, to: f64, rate: f64) -> f64 {
|
||||
from + angle_diff(from, to).clamp(-rate, rate)
|
||||
}
|
||||
|
||||
/// One axis of a drive: speeds up towards `err` (the angle still to turn), brakes in time to stop on it,
|
||||
/// and returns the turn this tick. Arriving takes the last bit at once, so there is no overshoot or jitter.
|
||||
fn drive(vel: &mut f64, err: f64, speed: f64, accel: f64) -> f64 {
|
||||
// The fastest it may go and still stop within what is left.
|
||||
let fastest = (2.0 * accel * err.abs()).sqrt().min(speed);
|
||||
let want = fastest.copysign(err);
|
||||
*vel = (*vel + (want - *vel).clamp(-accel, accel)).clamp(-speed, speed);
|
||||
if vel.abs() >= err.abs() && vel.signum() == err.signum() {
|
||||
*vel = 0.0;
|
||||
return err;
|
||||
}
|
||||
*vel
|
||||
}
|
||||
|
||||
/// Moves one system's claim from the drone it had to the one it has, in a count of claims per drone.
|
||||
fn retarget(counts: &mut HashMap<u32, u32>, from: Option<u32>, to: Option<u32>) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
if let Some(n) = from.and_then(|id| counts.get_mut(&id)) {
|
||||
*n = n.saturating_sub(1);
|
||||
}
|
||||
if let Some(id) = to {
|
||||
*counts.entry(id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn angle_diff(from: f64, to: f64) -> f64 {
|
||||
(to - from + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI
|
||||
}
|
||||
@@ -173,8 +209,13 @@ pub struct Ground {
|
||||
known: HashMap<(i32, i32), (i32, u64)>,
|
||||
wanted: Vec<(i32, i32)>,
|
||||
asked: std::collections::HashSet<(i32, i32)>,
|
||||
/// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer).
|
||||
swept: u64,
|
||||
}
|
||||
|
||||
/// Ticks between full clean-ups of a big ground cache.
|
||||
const SWEEP_EVERY: u64 = 200;
|
||||
|
||||
impl Ground {
|
||||
/// One above the highest solid block, if known; the column is asked for
|
||||
/// when it isn't (or is old).
|
||||
@@ -182,7 +223,9 @@ impl Ground {
|
||||
let key = (p.x.floor() as i32, p.z.floor() as i32);
|
||||
match self.known.get(&key) {
|
||||
Some(&(y, when)) => {
|
||||
if now.saturating_sub(when) > 200 && self.wanted.len() < 256 && self.asked.insert(key) {
|
||||
// Refreshes of known columns take at most a quarter of the questions: the rest
|
||||
// stay free for columns never seen, or those would starve.
|
||||
if now.saturating_sub(when) > 200 && self.wanted.len() < 64 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
(y > -64).then_some(f64::from(y))
|
||||
@@ -199,8 +242,9 @@ impl Ground {
|
||||
for (x, z, y) in answers {
|
||||
self.known.insert((x, z), (y, now));
|
||||
}
|
||||
if self.known.len() > 300_000 {
|
||||
self.known.retain(|_, (_, when)| now - *when < 2000);
|
||||
if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY {
|
||||
self.swept = now;
|
||||
self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000);
|
||||
}
|
||||
}
|
||||
pub fn take_wanted(&mut self) -> Vec<(i32, i32)> {
|
||||
@@ -361,6 +405,17 @@ impl DroneKind {
|
||||
DroneKind::OreshnikBlock,
|
||||
DroneKind::OreshnikSub,
|
||||
];
|
||||
/// The sound of a launch from the ground: a light catapult shot, a rocket booster, a heavy ballistic launch.
|
||||
pub fn launch_sound(self) -> &'static str {
|
||||
match self {
|
||||
DroneKind::Shahed | DroneKind::Geran3 | DroneKind::Lancet | DroneKind::Gerbera => "airdef:launch.s400_eject",
|
||||
DroneKind::Kh101 | DroneKind::Kalibr | DroneKind::Kh22 => "airdef:launch.patriot",
|
||||
DroneKind::Zircon => "airdef:launch.s400",
|
||||
DroneKind::Iskander | DroneKind::Kinzhal | DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => {
|
||||
"airdef:launch.s500"
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn spec(self) -> &'static DroneSpec {
|
||||
match self {
|
||||
DroneKind::Shahed => &DroneSpec {
|
||||
@@ -657,6 +712,8 @@ pub struct Drone {
|
||||
pub jammed: bool,
|
||||
/// The approach sector it came from (the enemy keeps score).
|
||||
sector: u8,
|
||||
/// Sent by the smart enemy: it may be turned on systems that give themselves away.
|
||||
smart: bool,
|
||||
}
|
||||
|
||||
impl Drone {
|
||||
@@ -777,7 +834,7 @@ impl MissileKind {
|
||||
rear: 32.0,
|
||||
},
|
||||
MissileKind::IrisT => &MissileSpec {
|
||||
model: "airdef:pac3",
|
||||
model: "airdef:irist_missile",
|
||||
speed: 7.0,
|
||||
accel: 0.5,
|
||||
turn: 0.35,
|
||||
@@ -791,7 +848,7 @@ impl MissileKind {
|
||||
rear: 28.0,
|
||||
},
|
||||
MissileKind::Amraam => &MissileSpec {
|
||||
model: "airdef:pac3",
|
||||
model: "airdef:amraam_missile",
|
||||
speed: 6.0,
|
||||
accel: 0.45,
|
||||
turn: 0.3,
|
||||
@@ -930,6 +987,9 @@ pub struct Sam {
|
||||
pub heading: f64,
|
||||
yaw: f64,
|
||||
pitch: f64,
|
||||
/// How fast the mount turns now (radians a tick); not saved, a loaded system starts at rest.
|
||||
yaw_vel: f64,
|
||||
pitch_vel: f64,
|
||||
pub missiles: u32,
|
||||
pub bursts: u32,
|
||||
cooldown: u32,
|
||||
@@ -983,6 +1043,8 @@ impl Sam {
|
||||
heading,
|
||||
yaw: heading,
|
||||
pitch: spec.rest_pitch,
|
||||
yaw_vel: 0.0,
|
||||
pitch_vel: 0.0,
|
||||
missiles: spec.missile.as_ref().map_or(0, |m| m.magazine),
|
||||
bursts: spec.gun.as_ref().map_or(0, |g| g.bursts),
|
||||
cooldown: 40,
|
||||
@@ -1022,6 +1084,21 @@ impl Sam {
|
||||
self.health > 0.0
|
||||
}
|
||||
/// How well it still works: turning, aiming, its radar's reach.
|
||||
/// Turns the mount towards `yaw` (and `pitch`, if it elevates) under its drive's limits; a damaged
|
||||
/// system has the less of both speed and acceleration.
|
||||
fn aim_towards(&mut self, yaw: f64, pitch: Option<f64>) {
|
||||
let d = self.kind.spec().drive();
|
||||
let fit = self.fitness();
|
||||
self.yaw += drive(&mut self.yaw_vel, angle_diff(self.yaw, yaw), d.yaw.0 * fit, d.yaw.1 * fit);
|
||||
if let Some(pitch) = pitch {
|
||||
self.pitch += drive(&mut self.pitch_vel, pitch - self.pitch, d.pitch.0 * fit, d.pitch.1 * fit);
|
||||
}
|
||||
}
|
||||
/// The mount stops where it is (a march, a deployment: the vehicle drives it directly).
|
||||
pub(super) fn halt_mount(&mut self) {
|
||||
self.yaw_vel = 0.0;
|
||||
self.pitch_vel = 0.0;
|
||||
}
|
||||
fn fitness(&self) -> f64 {
|
||||
0.45 + 0.55 * self.health.clamp(0.0, 1.0)
|
||||
}
|
||||
@@ -1088,6 +1165,8 @@ pub struct Sim {
|
||||
fx: Vec<(u32, Fx)>,
|
||||
/// Blasts for the post-processing shader.
|
||||
pub blasts: Vec<Blast>,
|
||||
/// The next blast's id (separate from `next`, so the other units' ids stay as they were).
|
||||
blast_seq: u32,
|
||||
next: u32,
|
||||
pub rng: Rng,
|
||||
pub tick: u64,
|
||||
@@ -1116,13 +1195,15 @@ pub struct Sim {
|
||||
rec: Option<record::Recording>,
|
||||
pub replays: Vec<record::Recording>,
|
||||
recordings: u32,
|
||||
/// What went wrong for the air defence, counted for the raid report and the recordings.
|
||||
pub tally: record::Tally,
|
||||
/// What the enemy has learned.
|
||||
intel: ew::Intel,
|
||||
/// Night: guns need light (the mod sets it from the game's clock).
|
||||
pub night: bool,
|
||||
/// Illumination flares in the sky, and the drones lit this tick.
|
||||
pub flares: Vec<lights::Flare>,
|
||||
lit: Vec<u32>,
|
||||
pub lit: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Ids: objects get multiples of 16, their parts the low bits.
|
||||
@@ -1138,6 +1219,7 @@ impl Sim {
|
||||
targets: Vec::new(),
|
||||
fx: Vec::new(),
|
||||
blasts: Vec::new(),
|
||||
blast_seq: 0,
|
||||
next: 1,
|
||||
rng: Rng::new(seed),
|
||||
tick: 0,
|
||||
@@ -1156,6 +1238,7 @@ impl Sim {
|
||||
rec: None,
|
||||
replays: Vec::new(),
|
||||
recordings: 0,
|
||||
tally: record::Tally::default(),
|
||||
intel: ew::Intel::default(),
|
||||
night: false,
|
||||
flares: Vec::new(),
|
||||
@@ -1230,6 +1313,7 @@ impl Sim {
|
||||
low: false,
|
||||
jammed: false,
|
||||
sector: 0,
|
||||
smart: false,
|
||||
});
|
||||
self.launched += 1;
|
||||
}
|
||||
@@ -1239,7 +1323,7 @@ impl Sim {
|
||||
let dir = DVec3::new(dir.x, dir.y.max(0.35), dir.z).normalize_or(DVec3::Y);
|
||||
let from = if kind.spec().ballistic.is_some() { from + DVec3::Y * 2.0 } else { from };
|
||||
self.add_drone(kind, from, dir, target);
|
||||
out.sound("airdef:launch.patriot", from, 1.0, 1.3);
|
||||
out.sound(kind.launch_sound(), from, 1.0, 1.3);
|
||||
out.particles("minecraft:large_smoke", from, 0.6, DVec3::ZERO, 0.05, 30);
|
||||
out.particles("minecraft:flame", from, 0.3, -dir * 0.2, 0.05, 12);
|
||||
}
|
||||
@@ -1269,6 +1353,10 @@ impl Sim {
|
||||
self.queue.clear();
|
||||
self.plan = None;
|
||||
self.wave = None;
|
||||
// Ammunition still to cook off, flares in the sky.
|
||||
self.later.clear();
|
||||
self.flares.clear();
|
||||
self.lit.clear();
|
||||
}
|
||||
|
||||
/// One game tick.
|
||||
@@ -1301,8 +1389,8 @@ impl Sim {
|
||||
let now = self.tick;
|
||||
let mut boom: Vec<(DVec3, u32, bool)> = Vec::new();
|
||||
let mut removed: Vec<u32> = Vec::new();
|
||||
// Oreshnik pieces released this tick: kind, at, velocity, target.
|
||||
let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3)> = Vec::new();
|
||||
// Oreshnik pieces released this tick: kind, at, velocity, target, the parent's sector (what they teach the enemy).
|
||||
let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3, u8)> = Vec::new();
|
||||
// Warheads that reached their aim (for the replays): drone, kind, where, TNT; and splits.
|
||||
let mut impacts: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
|
||||
let mut splits: Vec<(u32, DVec3)> = Vec::new();
|
||||
@@ -1310,6 +1398,15 @@ impl Sim {
|
||||
d.age += 1;
|
||||
let spec = d.kind.spec();
|
||||
let below = ground.at(d.pos, now);
|
||||
// Near its aim it looks at the land there again: a platform or hilltop blown away
|
||||
// meanwhile, it goes for what is left below, not for the air where it stood.
|
||||
if d.flight != Flight::Falling
|
||||
&& horizontal(d.target - d.pos).length() < 250.0
|
||||
&& let Some(g) = ground.at(d.target, now)
|
||||
&& g < d.target.y - 1.5
|
||||
{
|
||||
d.target.y = g;
|
||||
}
|
||||
if d.flight == Flight::Falling {
|
||||
d.vel.y -= 0.05;
|
||||
d.vel *= 0.985;
|
||||
@@ -1345,7 +1442,7 @@ impl Sim {
|
||||
let speed = d.vel.length();
|
||||
let hit = d.pos.distance(d.target) < speed.max(2.0)
|
||||
|| below.is_some_and(|g| d.pos.y <= g + 0.2)
|
||||
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0)
|
||||
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0 && horizontal(d.target - d.pos).length() < speed * 2.0)
|
||||
|| d.age > 4000;
|
||||
if hit {
|
||||
boom.push((d.pos, spec.warhead, false));
|
||||
@@ -1370,7 +1467,7 @@ impl Sim {
|
||||
let aim = d.target + DVec3::new(a.sin() * r, 0.0, a.cos() * r);
|
||||
// Straight down at the aim point, at the bus's speed.
|
||||
let v = (aim - d.pos).normalize_or(DVec3::NEG_Y) * 3.0;
|
||||
released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim));
|
||||
released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim, d.sector));
|
||||
}
|
||||
out.sound("airdef:boom.sonic", d.pos, 1.0, 0.7);
|
||||
splits.push((d.id, d.pos));
|
||||
@@ -1502,7 +1599,7 @@ impl Sim {
|
||||
self.explode(pos, warhead, small, ground, player, out);
|
||||
}
|
||||
self.drones.retain(|d| !removed.contains(&d.id));
|
||||
for (kind, pos, vel, target) in released {
|
||||
for (kind, pos, vel, target, sector) in released {
|
||||
let id = self.id();
|
||||
self.drones.push(Drone {
|
||||
id,
|
||||
@@ -1518,7 +1615,8 @@ impl Sim {
|
||||
route: Vec::new(),
|
||||
low: false,
|
||||
jammed: false,
|
||||
sector: 0,
|
||||
sector,
|
||||
smart: false,
|
||||
});
|
||||
}
|
||||
for d in &self.drones {
|
||||
@@ -1616,7 +1714,8 @@ impl Sim {
|
||||
}
|
||||
|
||||
fn fireball(&mut self, pos: DVec3, size: f64, life: u32) {
|
||||
self.blasts.push(Blast { at: pos, size: size as f32, start: self.tick, life: life * 9 });
|
||||
self.blast_seq = self.blast_seq.wrapping_add(1);
|
||||
self.blasts.push(Blast { id: self.blast_seq, at: pos, size: size as f32, start: self.tick, life: life * 9 });
|
||||
if self.blasts.len() > 48 {
|
||||
self.blasts.remove(0);
|
||||
}
|
||||
@@ -1680,6 +1779,12 @@ impl Sim {
|
||||
return;
|
||||
}
|
||||
let logistics = self.logistics();
|
||||
// Drones by id (none come or go while the systems act), and how many systems have each as their
|
||||
// missile target and as their gun target: counted once, then kept up to date as targets change.
|
||||
let drone_at: HashMap<u32, usize> = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect();
|
||||
let (mut missile_on, mut guns_on) = why::engagement_counts(&self.sams);
|
||||
// For the raid's report: tracks dropped and systems that ran dry and wait for a truck.
|
||||
let (mut tracks_lost, mut ammo_waits) = (0u32, 0u32);
|
||||
let mut launches: Vec<(MissileKind, DVec3, DVec3, u32, u32)> = Vec::new();
|
||||
let mut gun_hits: Vec<(u32, u32)> = Vec::new();
|
||||
let mut tracers: Vec<(u32, DVec3, DVec3, u32)> = Vec::new();
|
||||
@@ -1688,8 +1793,8 @@ impl Sim {
|
||||
if !self.sams[i].alive() {
|
||||
let sam = &mut self.sams[i];
|
||||
sam.state = SamState::Destroyed;
|
||||
sam.target = None;
|
||||
sam.gun_target = None;
|
||||
retarget(&mut missile_on, sam.target.take(), None);
|
||||
retarget(&mut guns_on, sam.gun_target.take(), None);
|
||||
continue;
|
||||
}
|
||||
let fit = self.sams[i].fitness();
|
||||
@@ -1715,60 +1820,82 @@ impl Sim {
|
||||
if !self.sams[i].kind.armed() {
|
||||
let sam = &mut self.sams[i];
|
||||
sam.state = SamState::Search;
|
||||
sam.target = None;
|
||||
sam.gun_target = None;
|
||||
// A searchlight's target is its beam's (step_lights): kept for its spot of light.
|
||||
if sam.kind != SamKind::Light {
|
||||
retarget(&mut missile_on, sam.target.take(), None);
|
||||
}
|
||||
retarget(&mut guns_on, sam.gun_target.take(), None);
|
||||
continue;
|
||||
}
|
||||
let pos = self.sams[i].pos;
|
||||
let me = self.sams[i].id;
|
||||
let roles = self.sams[i].roles;
|
||||
let seen = self.sams[i].seen.clone();
|
||||
let own = self.sams[i].own.clone();
|
||||
let allowed = |d: &Drone| d.alive() && roles & d.kind.spec().class.bit() != 0 && seen.contains(&d.id);
|
||||
// Interceptors a target has or will have: in flight, and other systems' launchers on it.
|
||||
let claimed = |d: &Drone| {
|
||||
d.engaged + self.sams.iter().filter(|s| s.id != me && s.target == Some(d.id)).count() as u32
|
||||
};
|
||||
// Ballistic missiles get a pair; anything else one.
|
||||
let needed = |d: &Drone| if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
|
||||
if let Some(load) = &spec.missile {
|
||||
let in_reach = |d: &Drone, k: f64| (load.min..load.max * k).contains(&d.pos.distance(pos));
|
||||
let keep = self.sams[i].target.filter(|t| {
|
||||
self.drones.iter().any(|d| d.id == *t && allowed(d) && in_reach(d, 1.1))
|
||||
let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target);
|
||||
let (target, gun_target) = {
|
||||
let (seen, own) = (&self.sams[i].seen, &self.sams[i].own);
|
||||
// The conditions are `why`'s, shared with the diagnostic.
|
||||
let allowed = |d: &Drone| why::allowed(roles, seen, d);
|
||||
let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target);
|
||||
let needed = why::needed;
|
||||
let target = spec.missile.as_ref().map(|load| {
|
||||
let in_reach = |d: &Drone, k: f64| why::in_reach(load.min, load.max, k, pos, d);
|
||||
let keep = had_target.filter(|t| {
|
||||
drone_at.get(t).is_some_and(|&k| allowed(&self.drones[k]) && in_reach(&self.drones[k], 1.1))
|
||||
});
|
||||
keep.or_else(|| {
|
||||
self.drones
|
||||
.iter()
|
||||
.filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d))
|
||||
.max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos)))
|
||||
.map(|d| d.id)
|
||||
})
|
||||
});
|
||||
let target = keep.or_else(|| {
|
||||
// Guns: the nearest, spread over the targets the other guns and missiles aren't on.
|
||||
let gun_target = spec.gun.as_ref().map(|gun| {
|
||||
let on_it = |d: &Drone| {
|
||||
(guns_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_gun == Some(d.id))) as f64
|
||||
};
|
||||
self.drones
|
||||
.iter()
|
||||
.filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d))
|
||||
.max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos)))
|
||||
.map(|d| d.id)
|
||||
.filter(|d| why::gun_candidate(roles, seen, own, gun.track, pos, d))
|
||||
.map(|d| {
|
||||
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
|
||||
(d.pos.distance(pos) * busy, d.id)
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, id)| id)
|
||||
});
|
||||
(target, gun_target)
|
||||
};
|
||||
if let Some(target) = target {
|
||||
// A target still in the sky that the system had and now doesn't: its track was lost.
|
||||
if let Some(h) = had_target
|
||||
&& target != Some(h)
|
||||
&& drone_at.get(&h).is_some_and(|&k| self.drones[k].alive())
|
||||
{
|
||||
tracks_lost += 1;
|
||||
}
|
||||
retarget(&mut missile_on, had_target, target);
|
||||
self.sams[i].target = target;
|
||||
}
|
||||
// Guns: the nearest, spread over the targets the other guns and missiles aren't on.
|
||||
if let Some(gun) = &spec.gun {
|
||||
let guns_on = |d: &Drone| {
|
||||
self.sams.iter().filter(|s| s.id != me && s.gun_target == Some(d.id)).count() as f64
|
||||
};
|
||||
self.sams[i].gun_target = self
|
||||
.drones
|
||||
.iter()
|
||||
.filter(|d| allowed(d) && own.contains(&d.id) && d.pos.distance(pos) < gun.track)
|
||||
.map(|d| {
|
||||
let busy = 1.0 + 2.0 * guns_on(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
|
||||
(d.pos.distance(pos) * busy, d.id)
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, id)| id);
|
||||
if let Some(gun_target) = gun_target {
|
||||
retarget(&mut guns_on, had_gun, gun_target);
|
||||
self.sams[i].gun_target = gun_target;
|
||||
}
|
||||
let sam = &mut self.sams[i];
|
||||
// What the turret or launcher points at: guns first when a target is close.
|
||||
let aim = sam.gun_target.or(sam.target).and_then(|t| self.drones.iter().find(|d| d.id == t));
|
||||
let aim = sam.gun_target.or(sam.target).and_then(|t| drone_at.get(&t).map(|&k| &self.drones[k]));
|
||||
sam.launched = sam.launched.saturating_sub(1);
|
||||
let Some(aim) = aim else {
|
||||
// Out of missiles and nothing to shoot: reload now, guns or not.
|
||||
if spec.missile.is_some() && sam.missiles == 0 && sam.reloading == 0 && !sam.empty {
|
||||
if logistics { sam.empty = true } else { sam.reloading = spec.reload }
|
||||
// Out of missiles or of shells, and nothing to shoot: reload now (both, as a
|
||||
// combined system's crew would), not only once both are spent.
|
||||
let spent = (spec.missile.is_some() && sam.missiles == 0) || (spec.gun.is_some() && sam.bursts == 0);
|
||||
if spent && sam.reloading == 0 && !sam.empty {
|
||||
if logistics {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
}
|
||||
}
|
||||
sam.state = if sam.reloading > 0 {
|
||||
SamState::Reload
|
||||
@@ -1777,31 +1904,24 @@ impl Sim {
|
||||
} else {
|
||||
SamState::Search
|
||||
};
|
||||
sam.yaw = slew(sam.yaw, sam.heading, 0.03);
|
||||
sam.pitch += (spec.rest_pitch - sam.pitch) * 0.05;
|
||||
// Back to the rest position, under the drive's limits like any other turn.
|
||||
sam.aim_towards(sam.heading, Some(spec.rest_pitch));
|
||||
continue;
|
||||
};
|
||||
let (mount, _) = sam.mount();
|
||||
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
|
||||
let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed);
|
||||
let to = lead - mount;
|
||||
let want_yaw = heading_of(to);
|
||||
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
|
||||
let (yaw_rate, pitch_rate) = spec.gun.as_ref().map_or((0.05, 0.05), |g| g.slew);
|
||||
let (yaw_rate, pitch_rate) = (yaw_rate * fit, pitch_rate * fit);
|
||||
let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim, lead_speed);
|
||||
let cells = matches!(spec.muzzle, Muzzle::Cells);
|
||||
match spec.family {
|
||||
// Vertical launch: no aiming (the Tor's turret turns its radar to the target).
|
||||
Family::Vertical if cells => sam.yaw = slew(sam.yaw, want_yaw, 0.15 * fit),
|
||||
Family::Vertical if cells => sam.aim_towards(want_yaw, None),
|
||||
Family::Vertical => {}
|
||||
Family::Trainable => sam.yaw = slew(sam.yaw, want_yaw, 0.12 * fit),
|
||||
_ => {
|
||||
sam.yaw = slew(sam.yaw, want_yaw, yaw_rate);
|
||||
sam.pitch = slew(sam.pitch, want_pitch, pitch_rate);
|
||||
}
|
||||
// The launcher's elevation is fixed (the missile turns after its boost): only the training is aimed.
|
||||
Family::Trainable => sam.aim_towards(want_yaw, None),
|
||||
_ => sam.aim_towards(want_yaw, Some(want_pitch)),
|
||||
}
|
||||
let aim_error = angle_diff(sam.yaw, want_yaw).abs()
|
||||
+ if spec.gun.is_some() { (want_pitch - sam.pitch).abs() } else { 0.0 };
|
||||
// How far the mount still is from the aim, over the axes this family aims.
|
||||
let aim_error = why::aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch);
|
||||
let vertical = spec.family == Family::Vertical;
|
||||
sam.state = if sam.reloading > 0 {
|
||||
SamState::Reload
|
||||
@@ -1820,15 +1940,20 @@ impl Sim {
|
||||
};
|
||||
// Guns.
|
||||
if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) {
|
||||
if let Some(t) = self.drones.iter().find(|d| d.id == gt) {
|
||||
if let Some(t) = drone_at.get(>).map(|&k| &self.drones[k]) {
|
||||
let dist = t.pos.distance(mount);
|
||||
if aim.id == gt && aim_error < 0.1 && dist < gun.range && sam.gun_cooldown == 0 && sam.reloading == 0 && sam.bursts > 0 {
|
||||
if why::gun_blocks(sam, aim.id, gt, aim_error, dist, gun.range) == 0 {
|
||||
sam.gun_cooldown = 2;
|
||||
sam.bursts -= 1;
|
||||
sam.flash = 2;
|
||||
sam.recoil = 1.0;
|
||||
if sam.bursts == 0 && sam.missiles == 0 {
|
||||
if logistics { sam.empty = true } else { sam.reloading = spec.reload }
|
||||
if logistics {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
}
|
||||
}
|
||||
let (_, rot) = sam.mount();
|
||||
let s = spec.scale;
|
||||
@@ -1838,7 +1963,7 @@ impl Sim {
|
||||
};
|
||||
for b in barrels {
|
||||
let muzzle = mount + rot * *b * s;
|
||||
let dir = (lead - muzzle).normalize();
|
||||
let dir = (lead - muzzle).normalize_or(DVec3::NEG_Z);
|
||||
let len = muzzle.distance(lead).min(gun.range * 1.2);
|
||||
// Every round is a red tracer flying out at the shell's speed.
|
||||
let scatter = DVec3::new(self.rng.s(), self.rng.s(), self.rng.s()) * 0.012;
|
||||
@@ -1865,32 +1990,40 @@ impl Sim {
|
||||
}
|
||||
// Missiles.
|
||||
if let (Some(load), Some(mt)) = (&spec.missile, sam.target) {
|
||||
let ready = sam.cooldown == 0 && sam.reloading == 0 && sam.missiles > 0;
|
||||
let aligned = match spec.family {
|
||||
Family::Trainable => {
|
||||
let t = self.drones.iter().find(|d| d.id == mt);
|
||||
// The missile turns after its boost: the launcher needn't be exact.
|
||||
t.is_some_and(|t| angle_diff(sam.yaw, heading_of(t.pos - mount)).abs() < 0.45)
|
||||
}
|
||||
Family::Turret => aim.id == mt && aim_error < 0.2,
|
||||
_ => true,
|
||||
};
|
||||
let ready = why::missile_blocks(sam) == 0;
|
||||
// The missile turns after its boost, but the launcher must point near the target.
|
||||
let t = drone_at.get(&mt).map(|&k| &self.drones[k]);
|
||||
let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim.id, mt, aim_error);
|
||||
if ready && aligned {
|
||||
let n = load.magazine - sam.missiles;
|
||||
let (muzzle, dir) = sam.muzzle(n);
|
||||
launches.push((load.kind, muzzle, dir, mt, sam.id));
|
||||
// Its missile is on it: the next launch looks for another target.
|
||||
// Its missile is on it from now, so the systems after this one this tick see
|
||||
// the target taken; this one's next launch looks for another.
|
||||
if let Some(&k) = drone_at.get(&mt) {
|
||||
self.drones[k].engaged += 1;
|
||||
}
|
||||
sam.target = None;
|
||||
retarget(&mut missile_on, Some(mt), None);
|
||||
sam.launched = 12;
|
||||
sam.missiles -= 1;
|
||||
sam.fired += 1;
|
||||
sam.cooldown = load.interval;
|
||||
if sam.missiles == 0 && (spec.gun.is_none() || sam.bursts == 0) {
|
||||
if logistics { sam.empty = true } else { sam.reloading = spec.reload }
|
||||
if logistics {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.note(|t| {
|
||||
t.tracks_lost += tracks_lost;
|
||||
t.ammo_waits += ammo_waits;
|
||||
});
|
||||
for (kind, muzzle, dir, target, sam) in launches {
|
||||
// A launch gives the system away.
|
||||
if let Some(s) = self.sams.iter().find(|s| s.id == sam) {
|
||||
@@ -1900,9 +2033,6 @@ impl Sim {
|
||||
let id = self.id();
|
||||
let v0 = if spec.cold > 0 { 0.7 } else { 0.8 };
|
||||
self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0 });
|
||||
if let Some(d) = self.drones.iter_mut().find(|d| d.id == target) {
|
||||
d.engaged += 1;
|
||||
}
|
||||
if spec.cold > 0 {
|
||||
// Thrown out by gas: a puff, a thump; the motor lights later.
|
||||
out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30);
|
||||
@@ -1933,6 +2063,7 @@ impl Sim {
|
||||
|
||||
fn step_missiles(&mut self, out: &mut Out) {
|
||||
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
|
||||
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
||||
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, s.fitness())).collect();
|
||||
for m in &mut self.missiles {
|
||||
m.age += 1;
|
||||
@@ -1971,11 +2102,18 @@ impl Sim {
|
||||
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
|
||||
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0);
|
||||
let kill = self.rng.chance(spec.pk * fit * ratio / t.kind.spec().stealth.sqrt());
|
||||
missed += u32::from(!kill);
|
||||
done.push((m.id, Some(t.id), kill, m.sam));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if m.age > spec.life || (target.is_none() && m.age > 30) {
|
||||
// Its target gone (shot down by another, or lost), or its life spent chasing one.
|
||||
if target.is_none() {
|
||||
orphaned += 1;
|
||||
} else {
|
||||
expired += 1;
|
||||
}
|
||||
done.push((m.id, None, false, m.sam));
|
||||
continue;
|
||||
}
|
||||
@@ -2009,6 +2147,11 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.note(|t| {
|
||||
t.missed += missed;
|
||||
t.expired += expired;
|
||||
t.orphaned += orphaned;
|
||||
});
|
||||
for (mid, target, kill, sam) in done {
|
||||
if let Some(i) = self.missiles.iter().position(|m| m.id == mid) {
|
||||
let m = self.missiles.swap_remove(i);
|
||||
|
||||
@@ -25,6 +25,10 @@ impl SamKind {
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// A reload truck's job: the system or depot it is going to.
|
||||
pub fn job(&self) -> Option<u32> {
|
||||
self.job
|
||||
}
|
||||
/// Packing up, driving or deploying: not fighting.
|
||||
pub fn moving(&self) -> bool {
|
||||
matches!(self.state, SamState::Packing | SamState::Moving | SamState::Deploying)
|
||||
@@ -56,6 +60,10 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
let spec = sam.kind.spec();
|
||||
// The march and the packing drive the mount themselves: no speed carries over into or out of them.
|
||||
if matches!(sam.state, SamState::Packing | SamState::Moving | SamState::Deploying) {
|
||||
sam.halt_mount();
|
||||
}
|
||||
match sam.state {
|
||||
SamState::Packing => {
|
||||
// The launcher comes down for the march.
|
||||
|
||||
+60
-14
@@ -95,23 +95,24 @@ pub(super) struct WaveStats {
|
||||
sams: usize,
|
||||
objects: f64,
|
||||
size: u32,
|
||||
tally: record::Tally,
|
||||
}
|
||||
|
||||
/// The planner's grid: cells this big, at most this many a side.
|
||||
const CELL: f64 = 16.0;
|
||||
const CELLS: i32 = 140;
|
||||
pub(super) const CELL: f64 = 16.0;
|
||||
pub(super) const CELLS: i32 = 140;
|
||||
/// The planner's work a tick (cells priced and opened): a raid takes a few ticks to plan, never a long one.
|
||||
const WORK_PER_TICK: usize = 40_000;
|
||||
pub(super) const WORK_PER_TICK: usize = 40_000;
|
||||
|
||||
/// The planner's grid over a raid: its corner, its cells' size and how many a side.
|
||||
struct Grid {
|
||||
min: DVec3,
|
||||
cell: f64,
|
||||
size: i32,
|
||||
pub(super) struct Grid {
|
||||
pub(super) min: DVec3,
|
||||
pub(super) cell: f64,
|
||||
pub(super) size: i32,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn index(&self, p: DVec3) -> (i32, i32) {
|
||||
pub(super) fn index(&self, p: DVec3) -> (i32, i32) {
|
||||
let n = self.size;
|
||||
((((p.x - self.min.x) / self.cell) as i32).clamp(0, n - 1), (((p.z - self.min.z) / self.cell) as i32).clamp(0, n - 1))
|
||||
}
|
||||
@@ -121,7 +122,7 @@ impl Grid {
|
||||
}
|
||||
|
||||
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
|
||||
fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
let mut opened = 0;
|
||||
let n = grid.size;
|
||||
let at = |(i, j): (i32, i32)| (i * n + j) as usize;
|
||||
@@ -195,6 +196,11 @@ impl Sim {
|
||||
/// (each route found in full, a few a tick) and launched when its plan is ready.
|
||||
pub fn order_raid(&mut self, order: &RaidOrder, objects: &[DVec3], out: &mut Out) -> u32 {
|
||||
let Some(¢re) = objects.first() else { return 0 };
|
||||
// One raid is planned at a time: another order waits for this one's drones to be sent.
|
||||
if self.plan.is_some() {
|
||||
out.messages.push("Противник ещё готовит прошлый налёт: подожди пару секунд".into());
|
||||
return 0;
|
||||
}
|
||||
let total: u32 = order.kinds.iter().map(|k| k.1).sum();
|
||||
if total == 0 {
|
||||
return 0;
|
||||
@@ -298,7 +304,8 @@ impl Sim {
|
||||
// Timing: smart raids arrive at once; others leave at once (a moment apart).
|
||||
let now = self.tick;
|
||||
let arrive = plan.done.iter().map(|(_, t, e, g)| t + e - g).fold(0.0, f64::max);
|
||||
for (d, time, early, gap) in plan.done {
|
||||
for (mut d, time, early, gap) in plan.done {
|
||||
d.smart = plan.smart;
|
||||
let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
|
||||
self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d));
|
||||
}
|
||||
@@ -310,6 +317,7 @@ impl Sim {
|
||||
sams: self.sams.iter().filter(|s| s.alive()).count(),
|
||||
objects: self.target_health.iter().sum(),
|
||||
size: plan.total,
|
||||
tally: self.tally,
|
||||
});
|
||||
let what = if plan.smart { "умный противник" } else { plan.approach.name() };
|
||||
out.messages.push(format!("ВОЗДУШНАЯ ТРЕВОГА: налёт, {} целей ({what})", plan.total));
|
||||
@@ -348,6 +356,7 @@ impl Sim {
|
||||
low: false,
|
||||
jammed: false,
|
||||
sector: 0,
|
||||
smart: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +403,7 @@ impl Sim {
|
||||
|
||||
/// The price of each cell of `grid` for this kind flying low: 1, and much more where a
|
||||
/// system sees it there (its radar's reach for it, the land) and can shoot it.
|
||||
fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
||||
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
||||
let mut cost = 0;
|
||||
let spec = kind.spec();
|
||||
let height = spec.clearance.max(6.0);
|
||||
@@ -419,8 +428,16 @@ impl Sim {
|
||||
let sp = s.kind.spec();
|
||||
let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max));
|
||||
let gun = sp.gun.as_ref().map_or(0.0, |g| g.range);
|
||||
let radar = sight::reach(s, kind, height);
|
||||
let far = hi.max(gun).min(radar);
|
||||
// A gun needs its own radar; a missile also flies on the tracks of any radar within data-link range.
|
||||
let own = sight::reach(s, kind, height);
|
||||
let net = self
|
||||
.sams
|
||||
.iter()
|
||||
.filter(|r| r.alive() && r.id != s.id && r.pos.distance(s.pos) <= sight::LINK)
|
||||
.map(|r| sight::reach(r, kind, height))
|
||||
.fold(0.0, f64::max);
|
||||
let (hi, gun) = (hi.min(own.max(net)), gun.min(own));
|
||||
let far = hi.max(gun);
|
||||
let at = sight::antenna(s);
|
||||
let horizon = self.horizons.get(&s.id);
|
||||
// Only the cells round it.
|
||||
@@ -471,7 +488,8 @@ impl Sim {
|
||||
let kills = self.kills - w.kills;
|
||||
let through = self.hits - w.hits;
|
||||
let launched = self.launched - w.launched;
|
||||
let fired: u32 = self.sams.iter().map(|s| s.fired).sum::<u32>() - w.fired;
|
||||
// A system taken away during the raid takes its count along: never below none.
|
||||
let fired: u32 = self.sams.iter().map(|s| s.fired).sum::<u32>().saturating_sub(w.fired);
|
||||
let lost = w.sams.saturating_sub(self.sams.iter().filter(|s| s.alive()).count());
|
||||
let objects = if self.target_health.is_empty() {
|
||||
String::new()
|
||||
@@ -483,6 +501,11 @@ impl Sim {
|
||||
"Налёт окончен: сбито {kills} из {launched} (заявлено {}), прорвалось {through}; ракет ПВО {fired}; потеряно комплексов {lost}{objects}",
|
||||
w.size
|
||||
));
|
||||
// Only what was counted; nothing is guessed for the rest.
|
||||
let why = self.tally.since(w.tally).text();
|
||||
if !why.is_empty() {
|
||||
out.messages.push(format!("Неудачи ПВО: {why}"));
|
||||
}
|
||||
self.wave = None;
|
||||
}
|
||||
}
|
||||
@@ -537,6 +560,29 @@ mod tests {
|
||||
eprintln!("4000 ticks in {:?}, kills {} hits {}", t.elapsed(), sim.kills, sim.hits);
|
||||
assert!(sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()), "the raid never ended");
|
||||
}
|
||||
#[test]
|
||||
fn systems_ready_together_send_one_missile_at_a_drone() {
|
||||
let mut sim = Sim::new(3);
|
||||
// Vertical launchers of one kind side by side: the drone comes into their reach in the
|
||||
// same tick, and they fire without turning.
|
||||
for i in 0..6 {
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(2.0 * i as f64, 70.0, 0.0), 0.0));
|
||||
}
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for _ in 0..30 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, -400.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
|
||||
let id = sim.drones[0].id;
|
||||
for _ in 0..1200 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let on_it = sim.missiles.iter().filter(|m| m.target == id).count();
|
||||
assert!(on_it <= 1, "{on_it} missiles on one Shahed");
|
||||
}
|
||||
assert!(sim.sams.iter().any(|s| s.fired > 0), "nothing was fired");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -48,6 +48,48 @@ pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// Why interceptors and systems failed, as far as it was counted: only these are known.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
||||
pub struct Tally {
|
||||
/// Interceptors that reached their fuse and did not kill (the hit chance failed).
|
||||
pub missed: u32,
|
||||
/// Interceptors whose life ran out with the target still flying.
|
||||
pub expired: u32,
|
||||
/// Interceptors whose target was gone (shot down by another, or lost) before they arrived.
|
||||
pub orphaned: u32,
|
||||
/// Times a system lost the track of the target it held, which was still in the sky.
|
||||
pub tracks_lost: u32,
|
||||
/// Times a system ran dry and began waiting for a reload truck.
|
||||
pub ammo_waits: u32,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
pub fn since(self, start: Tally) -> Tally {
|
||||
Tally {
|
||||
missed: self.missed.saturating_sub(start.missed),
|
||||
expired: self.expired.saturating_sub(start.expired),
|
||||
orphaned: self.orphaned.saturating_sub(start.orphaned),
|
||||
tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost),
|
||||
ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits),
|
||||
}
|
||||
}
|
||||
/// The non-zero counts as a line, or nothing.
|
||||
pub fn text(&self) -> String {
|
||||
let parts: Vec<String> = [
|
||||
(self.missed, "промахов по взрывателю"),
|
||||
(self.expired, "ракет выработали ресурс"),
|
||||
(self.orphaned, "ракет остались без цели"),
|
||||
(self.tracks_lost, "потерь трека"),
|
||||
(self.ammo_waits, "ожиданий БК"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|p| p.0 > 0)
|
||||
.map(|p| format!("{} {}", p.0, p.1))
|
||||
.collect();
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Recording {
|
||||
/// Which raid of the session (1, 2, …).
|
||||
pub number: u32,
|
||||
@@ -57,6 +99,8 @@ pub struct Recording {
|
||||
pub tracks: Vec<Track>,
|
||||
index: HashMap<u32, usize>,
|
||||
pub events: Vec<Event>,
|
||||
/// The failures counted during the raid.
|
||||
pub tally: Tally,
|
||||
/// The systems and objects as they stood: (id, kind, where).
|
||||
pub sams: Vec<(u32, SamKind, DVec3)>,
|
||||
pub objects: Vec<DVec3>,
|
||||
@@ -116,6 +160,7 @@ impl Recording {
|
||||
tracks: Vec::new(),
|
||||
index: HashMap::new(),
|
||||
events: Vec::new(),
|
||||
tally: Tally::default(),
|
||||
sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(),
|
||||
objects: sim.targets.clone(),
|
||||
quiet: 0,
|
||||
@@ -143,6 +188,21 @@ impl Recording {
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// The recording of raid `number`, while it is kept.
|
||||
pub fn replay(&self, number: u32) -> Option<&Recording> {
|
||||
self.replays.iter().find(|r| r.number == number)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Counts something in the session's tally and in the recording going on.
|
||||
pub(super) fn note(&mut self, f: impl Fn(&mut Tally)) {
|
||||
f(&mut self.tally);
|
||||
if let Some(r) = &mut self.rec {
|
||||
f(&mut r.tally);
|
||||
}
|
||||
}
|
||||
|
||||
/// Something worth a replay happened.
|
||||
pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) {
|
||||
if let Some(r) = &mut self.rec {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Regression tests for the issues filed on Gitea (each names its issue).
|
||||
use super::*;
|
||||
use super::raid::Grid;
|
||||
|
||||
// #1: [bug] Запросы рельефа голодают: часть направлений РЛС никогда не получает высоты
|
||||
#[test]
|
||||
fn terrain_queries_must_eventually_cover_all_radar_bearings() {
|
||||
let mut sim=Sim::new(1);
|
||||
for i in 0..10 {
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(i as f64*5000.,70.,0.),0.));
|
||||
}
|
||||
let mut ground=Ground::default();
|
||||
for now in 1..=2500 {
|
||||
sim.tick=now;sim.update_sight(&mut ground);
|
||||
let answers=ground.take_wanted().into_iter().map(|(x,z)|(x,z,200)).collect();
|
||||
ground.answer(answers,now);
|
||||
}
|
||||
let visible:Vec<usize>=sim.horizons.values().map(|h|h.rays.iter().filter(|r|r.last().is_some_and(|v|v.is_finite())).count()).collect();
|
||||
eprintln!("radar terrain coverage after 125 seconds: {:?} / 48 bearings each",visible);
|
||||
assert!(visible.iter().all(|n|*n==48),"refreshes of early rays starve unknown terrain in later rays");
|
||||
}
|
||||
|
||||
// #2: [bug] Уничтоженная ТЗМ навсегда резервирует заявку на подвоз боеприпасов
|
||||
#[test]
|
||||
fn dead_truck_must_not_reserve_a_waiting_system() {
|
||||
let mut sim=Sim::new(1);
|
||||
let depot=sim.id(); sim.sams.push(Sam::new(depot,SamKind::Depot,DVec3::new(500.,70.,0.),0.));
|
||||
let target=sim.id(); let mut s=Sam::new(target,SamKind::Tor,DVec3::new(0.,70.,0.),0.);s.empty=true;s.missiles=0;sim.sams.push(s);
|
||||
let dead=sim.id();let mut s=Sam::new(dead,SamKind::Tzm,DVec3::new(100.,70.,0.),0.);s.job=Some(target);s.health=0.;sim.sams.push(s);
|
||||
let live=sim.id();sim.sams.push(Sam::new(live,SamKind::Tzm,DVec3::new(30.,70.,0.),0.));
|
||||
sim.tick=3;sim.step_supply(&mut Out::default());
|
||||
assert_eq!(sim.sams[3].job,Some(target),"a live loaded truck must take the waiting system");
|
||||
}
|
||||
|
||||
// #3: [bug] ТЗМ застревает, если обслуживаемая установка сменила позицию
|
||||
#[test]
|
||||
fn truck_must_follow_a_target_that_moved_away() {
|
||||
let mut sim=Sim::new(1);
|
||||
let depot=sim.id();sim.sams.push(Sam::new(depot,SamKind::Depot,DVec3::new(500.,70.,0.),0.));
|
||||
let target=sim.id();let mut s=Sam::new(target,SamKind::Tor,DVec3::new(100.,70.,0.),0.);s.empty=true;s.missiles=0;sim.sams.push(s);
|
||||
let truck=sim.id();let mut s=Sam::new(truck,SamKind::Tzm,DVec3::new(0.,70.,0.),0.);s.job=Some(target);sim.sams.push(s);
|
||||
sim.tick=2;sim.step_supply(&mut Out::default());
|
||||
assert!(sim.sams[2].moving(),"truck at obsolete rendezvous must get a new move order");
|
||||
}
|
||||
|
||||
// #4: [bug] Планировщик умного налёта не учитывает пусковую с внешним целеуказанием
|
||||
#[test]
|
||||
fn network_launcher_must_contribute_to_route_risk() {
|
||||
let mut sim=Sim::new(1);
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::P1sun,DVec3::new(0.,70.,0.),0.));
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(0.,70.,0.),0.));
|
||||
let grid=Grid{min:DVec3::new(-160.,0.,-160.),cell:16.,size:20};
|
||||
let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed);
|
||||
assert!(prices.iter().any(|p|*p>1.),"armed launcher with a network radar is invisible to route risk");
|
||||
}
|
||||
|
||||
// #5: [bug] После ремонта комплекс снова уничтожается от собственной отложенной детонации
|
||||
#[test]
|
||||
fn repair_must_cancel_pending_magazine_explosions() {
|
||||
let mut sim=Sim::new(1);
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Patriot,DVec3::new(0.,70.,0.),0.));
|
||||
sim.blast_damage(DVec3::new(0.,70.,0.),4,&mut Out::default());
|
||||
assert!(!sim.later.is_empty());sim.repair();
|
||||
sim.tick=3; sim.step_later(&mut Ground::default(),None,&mut Out::default());
|
||||
assert!(sim.sams[0].alive(),"a fully repaired launcher was destroyed again by its own pre-repair cook-off");
|
||||
}
|
||||
|
||||
// #34: drones and ballistic missiles burst where their target's platform used to be.
|
||||
#[test]
|
||||
fn munitions_must_go_down_to_the_land_left_under_a_destroyed_target() {
|
||||
for (kind, from, vel) in [
|
||||
(DroneKind::Shahed, DVec3::new(0.0, 121.0, 0.0), DVec3::ZERO),
|
||||
(DroneKind::OreshnikSub, DVec3::new(0.0, 124.0, 0.0), DVec3::ZERO),
|
||||
(DroneKind::Iskander, DVec3::new(100.0, 121.0, 0.0), DVec3::new(0.0, -4.0, 0.0)),
|
||||
] {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
// The land under the old platform (at y 120) is at 64 now.
|
||||
for x in -120..=120 {
|
||||
for z in -8..=8 {
|
||||
ground.answer(vec![(x, z, 64)], 0);
|
||||
}
|
||||
}
|
||||
sim.add_drone(kind, from, DVec3::NEG_Y, DVec3::new(0.0, 120.0, 0.0));
|
||||
if vel != DVec3::ZERO {
|
||||
sim.drones[0].vel = vel;
|
||||
sim.drones[0].flight = Flight::Ballistic;
|
||||
}
|
||||
let mut out = Out::default();
|
||||
for _ in 0..400 {
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
let blast = sim.blasts.first().map(|b| b.at);
|
||||
assert!(blast.is_some_and(|b| b.y < 80.0), "{kind:?} burst at {blast:?}, in the air over the land");
|
||||
}
|
||||
}
|
||||
|
||||
// #15: lights of blasts in one tick at the same whole X must stay apart
|
||||
#[test]
|
||||
fn blasts_in_one_tick_have_their_own_ids() {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.tick = 100;
|
||||
sim.fireball(DVec3::new(0.0, 70.0, 100.0), 4.0, 10);
|
||||
sim.fireball(DVec3::new(0.0, 70.0, 200.0), 4.0, 10);
|
||||
assert_ne!(sim.blasts[0].id, sim.blasts[1].id);
|
||||
// A blast going away does not hand its id to the next one.
|
||||
let gone = sim.blasts.remove(0).id;
|
||||
sim.fireball(DVec3::new(0.0, 70.0, 300.0), 4.0, 10);
|
||||
assert!(sim.blasts.iter().all(|b| b.id != gone));
|
||||
}
|
||||
|
||||
// #21: a smart drone sent at a new target gets a route again, not a straight line
|
||||
#[test]
|
||||
fn retargeted_smart_drone_gets_a_route_round_the_defences() {
|
||||
let mut sim = Sim::new(1);
|
||||
// A system in the way, between the drone and the unit it is sent at.
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let goal = sim.id();
|
||||
sim.sams.push(Sam::new(goal, SamKind::Ew, DVec3::new(0.0, 70.0, 1000.0), 0.0));
|
||||
sim.intel.revealed.push((goal, DVec3::new(0.0, 70.0, 1000.0), 0));
|
||||
// Its first target, a system that is destroyed by now.
|
||||
let id = sim.id();
|
||||
let mut wreck = Sam::new(id, SamKind::Patriot, DVec3::new(3000.0, 70.0, -1000.0), 0.0);
|
||||
wreck.health = 0.0;
|
||||
sim.sams.push(wreck);
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, -1000.0), DVec3::Z, DVec3::new(3000.0, 70.0, -1000.0));
|
||||
sim.drones[0].pos = DVec3::new(0.0, 100.0, -1000.0);
|
||||
sim.drones[0].smart = true;
|
||||
sim.tick = 40;
|
||||
sim.adapt();
|
||||
let d = &sim.drones[0];
|
||||
assert!(d.target.distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit");
|
||||
assert!(d.route.len() > 1, "no route after the new target: it flies straight through the defences");
|
||||
}
|
||||
|
||||
// #31: a big cache of fresh columns is not scanned on every answer
|
||||
#[test]
|
||||
fn big_ground_cache_is_swept_rarely() {
|
||||
let mut ground = Ground::default();
|
||||
ground.known.extend((0..300_001).map(|x| ((x, 0), (64, 2000))));
|
||||
ground.known.insert((-1, 0), (64, 0));
|
||||
// The first answer past the threshold sweeps what is stale and keeps what is fresh.
|
||||
ground.answer(vec![(-2, 0, 64)], 2100);
|
||||
assert!(!ground.known.contains_key(&(-1, 0)), "a stale column was never swept");
|
||||
assert!(ground.known.len() > 300_000);
|
||||
// The next ones wait their pause: a stale column added now survives them.
|
||||
ground.known.insert((-1, 0), (64, 0));
|
||||
let swept = ground.swept;
|
||||
ground.answer(vec![(-3, 0, 64)], 2101);
|
||||
assert_eq!(ground.swept, swept, "swept again at once");
|
||||
assert!(ground.known.contains_key(&(-1, 0)));
|
||||
ground.answer(vec![(-4, 0, 64)], 2100 + SWEEP_EVERY);
|
||||
assert!(!ground.known.contains_key(&(-1, 0)), "never swept again");
|
||||
}
|
||||
|
||||
// #32: the pieces of an Oreshnik teach the enemy about the sector it came from
|
||||
#[test]
|
||||
fn oreshnik_pieces_keep_the_sector() {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Oreshnik, DVec3::new(100.0, 300.0, 0.0), DVec3::NEG_Y, DVec3::new(0.0, 70.0, 0.0));
|
||||
sim.drones[0].vel = DVec3::new(-1.0, -1.0, 0.0);
|
||||
sim.drones[0].sector = 7;
|
||||
let mut out = Out::default();
|
||||
sim.step_drones(&mut Ground::default(), None, &mut out);
|
||||
let pieces: Vec<_> = sim.drones.iter().filter(|d| d.kind == DroneKind::OreshnikBlock).collect();
|
||||
assert_eq!(pieces.len(), 6);
|
||||
assert!(pieces.iter().all(|d| d.sector == 7));
|
||||
}
|
||||
|
||||
// #33: a turret does not fire before it has turned to the target
|
||||
#[test]
|
||||
fn turret_waits_for_its_aim() {
|
||||
let mut sim = Sim::new(1);
|
||||
let id = sim.id();
|
||||
let mut s = Sam::new(id, SamKind::Gepard, DVec3::new(0.0, 70.0, 0.0), 0.0);
|
||||
s.cooldown = 0;
|
||||
sim.sams.push(s);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
// Straight behind the turret's heading, a slow target well inside the gun's reach.
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 60.0), DVec3::ZERO, DVec3::new(0.0, 70.0, 900.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
let mut turned = 0;
|
||||
for _ in 0..200 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let g = &sim.sams[0];
|
||||
let want = heading_of(sim.drones[0].pos - g.mount().0);
|
||||
if angle_diff(g.yaw, want).abs() > 0.1 {
|
||||
assert_eq!(g.bursts, 160, "fired with the turret still turning");
|
||||
turned += 1;
|
||||
}
|
||||
if g.bursts < 160 || sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(turned > 20, "the turret turned too fast to tell ({turned} ticks)");
|
||||
assert!(sim.sams[0].bursts < 160, "never fired");
|
||||
}
|
||||
|
||||
// #33: the drive speeds up, brakes in time and does not overshoot
|
||||
#[test]
|
||||
fn drive_arrives_without_overshoot() {
|
||||
for err in [std::f64::consts::PI, 1.57, 0.3, 0.02] {
|
||||
let (mut vel, mut left) = (0.0, err);
|
||||
let (speed, accel) = (0.088, 0.011);
|
||||
let mut ticks = 0;
|
||||
while left > 0.0 {
|
||||
let step = drive(&mut vel, left, speed, accel);
|
||||
assert!(step.abs() <= speed + 1e-9 && step >= 0.0, "step {step} at {left} left");
|
||||
left -= step;
|
||||
ticks += 1;
|
||||
assert!(ticks < 400, "never arrived for {err}");
|
||||
}
|
||||
assert!(left.abs() < 1e-9 && vel == 0.0);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ 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).
|
||||
rays: Vec<Vec<f32>>,
|
||||
pub(super) rays: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl Horizon {
|
||||
@@ -90,7 +90,8 @@ impl Sim {
|
||||
self.horizons.insert(sam.id, h);
|
||||
}
|
||||
}
|
||||
self.horizons.retain(|id, _| self.sams.iter().any(|s| s.id == *id && s.alive()));
|
||||
let alive: std::collections::HashSet<u32> = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect();
|
||||
self.horizons.retain(|id, _| alive.contains(id));
|
||||
// Each drone's height over the land under it (or over the systems' ground, unknown).
|
||||
let heights: Vec<Option<f64>> =
|
||||
self.drones.iter().map(|d| ground.at(d.pos, now).map(|g| d.pos.y - g)).collect();
|
||||
@@ -120,18 +121,20 @@ impl Sim {
|
||||
// The network: every unit gets the tracks of the radars within data-link range.
|
||||
let tracks: Vec<(DVec3, Vec<u32>)> =
|
||||
self.sams.iter().filter(|s| !s.own.is_empty()).map(|s| (s.pos, s.own.clone())).collect();
|
||||
// Merged in the radars' order, each track once: a set marks what is in already.
|
||||
let mut have: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for sam in &mut self.sams {
|
||||
sam.seen.clone_from(&sam.own);
|
||||
if !sam.alive() || sam.moving() || !sam.kind.armed() {
|
||||
sam.seen = sam.own.clone();
|
||||
continue;
|
||||
}
|
||||
let mut seen = sam.own.clone();
|
||||
have.clear();
|
||||
have.extend(sam.own.iter().copied());
|
||||
for (at, ids) in &tracks {
|
||||
if at.distance(sam.pos) <= LINK {
|
||||
seen.extend(ids.iter().copied().filter(|id| !seen.contains(id)).collect::<Vec<_>>());
|
||||
sam.seen.extend(ids.iter().copied().filter(|id| have.insert(*id)));
|
||||
}
|
||||
}
|
||||
sam.seen = seen;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,19 @@ pub(super) struct GunSpec {
|
||||
pub slew: (f64, f64),
|
||||
}
|
||||
|
||||
/// How fast a weapon's mount turns: per axis the top speed (radians a tick) and the acceleration, which
|
||||
/// is also the braking (radians a tick²). Game settings, not the real machines' figures.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Drive {
|
||||
pub yaw: (f64, f64),
|
||||
pub pitch: (f64, f64),
|
||||
}
|
||||
|
||||
/// The drives are this share of the speeds they had before they had a speed of their own.
|
||||
const DRIVE_SCALE: f64 = 0.4;
|
||||
/// Ticks a drive takes to reach its top speed (and to stop from it).
|
||||
const DRIVE_RAMP: f64 = 8.0;
|
||||
|
||||
pub struct SamSpec {
|
||||
pub name: &'static str,
|
||||
pub key: &'static str,
|
||||
@@ -122,6 +135,20 @@ pub struct SamSpec {
|
||||
pub mast: f64,
|
||||
}
|
||||
|
||||
impl SamSpec {
|
||||
/// The mount's drive: the guns' own slew rates, the old rates of the launchers, scaled down.
|
||||
pub(super) fn drive(&self) -> Drive {
|
||||
let (yaw, pitch) = match (&self.gun, self.family) {
|
||||
(Some(g), _) => g.slew,
|
||||
(None, Family::Trainable) => (0.12, 0.0),
|
||||
(None, Family::Vertical) => (0.15, 0.0),
|
||||
(None, _) => (0.05, 0.05),
|
||||
};
|
||||
let axis = |speed: f64| (speed * DRIVE_SCALE, speed * DRIVE_SCALE / DRIVE_RAMP);
|
||||
Drive { yaw: axis(yaw), pitch: axis(pitch) }
|
||||
}
|
||||
}
|
||||
|
||||
const NO_PARTS: Parts = Parts { body: "", launcher: None, pack: None, dish: None, fixed: &[], spinner: None };
|
||||
/// The battery radar trailer behind a vertical launcher.
|
||||
const BATTERY_RADAR: &[(&str, f64, f64)] = &[("airdef:radar_trailer", 0.0, 4.2)];
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! The conditions that decide whether a system fires, as predicates: `step_sams` decides with
|
||||
//! them, and `Sim::why` lists them for a chosen system (`/airdef why`, the monitor), so the
|
||||
//! explanation can't drift from the behaviour. Each predicate returns the set of reasons it
|
||||
//! fails (bits; 0 is a pass), so the diagnostic names the reasons and the simulation only asks
|
||||
//! whether there are none.
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- choosing a target
|
||||
|
||||
/// Why a drone is not a candidate for a system at all.
|
||||
pub(super) const DEAD: u8 = 1;
|
||||
pub(super) const ROLE: u8 = 2;
|
||||
pub(super) const UNSEEN: u8 = 4;
|
||||
|
||||
pub(super) fn target_blocks(roles: u8, seen: &[u32], d: &Drone) -> u8 {
|
||||
(if d.alive() { 0 } else { DEAD })
|
||||
| (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE })
|
||||
| (if seen.contains(&d.id) { 0 } else { UNSEEN })
|
||||
}
|
||||
|
||||
/// Alive, of a class the system engages, and on its picture (own radar or the network's).
|
||||
pub(super) fn allowed(roles: u8, seen: &[u32], d: &Drone) -> bool {
|
||||
target_blocks(roles, seen, d) == 0
|
||||
}
|
||||
|
||||
pub(super) const TOO_CLOSE: u8 = 1;
|
||||
pub(super) const TOO_FAR: u8 = 2;
|
||||
|
||||
/// Missile range: `min` to `max * k` (a target already held may stray to 1.1 × max).
|
||||
pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> u8 {
|
||||
let dist = d.pos.distance(pos);
|
||||
(if dist >= min { 0 } else { TOO_CLOSE }) | (if dist < max * k { 0 } else { TOO_FAR })
|
||||
}
|
||||
|
||||
pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
reach_blocks(min, max, k, pos, d) == 0
|
||||
}
|
||||
|
||||
/// Interceptors a target has or will have: in flight, and other systems' launchers on it.
|
||||
pub(super) fn claimed(d: &Drone, missile_on: &HashMap<u32, u32>, had_target: Option<u32>) -> u32 {
|
||||
let on = missile_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_target == Some(d.id));
|
||||
d.engaged + on
|
||||
}
|
||||
|
||||
/// Ballistic missiles get a pair; anything else one.
|
||||
pub(super) fn needed(d: &Drone) -> u32 {
|
||||
if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }
|
||||
}
|
||||
|
||||
/// A gun's candidates: held by the system's own radar and within the gun's tracking range.
|
||||
pub(super) fn gun_candidate(roles: u8, seen: &[u32], own: &[u32], track: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
allowed(roles, seen, d) && own.contains(&d.id) && d.pos.distance(pos) < track
|
||||
}
|
||||
|
||||
/// How many systems have each drone as their missile target and as their gun target.
|
||||
pub(super) fn engagement_counts(sams: &[Sam]) -> (HashMap<u32, u32>, HashMap<u32, u32>) {
|
||||
let mut missile_on = HashMap::new();
|
||||
let mut guns_on = HashMap::new();
|
||||
for s in sams {
|
||||
retarget(&mut missile_on, None, s.target);
|
||||
retarget(&mut guns_on, None, s.gun_target);
|
||||
}
|
||||
(missile_on, guns_on)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- aiming
|
||||
|
||||
/// Where the lead point is and the yaw and pitch that point the mount at it.
|
||||
pub(super) fn aim_wants(mount: DVec3, aim: &Drone, lead_speed: f64) -> (DVec3, f64, f64) {
|
||||
let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed);
|
||||
let to = lead - mount;
|
||||
let want_yaw = heading_of(to);
|
||||
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
|
||||
(lead, want_yaw, want_pitch)
|
||||
}
|
||||
|
||||
/// How far the mount still is from the aim, over the axes this family aims.
|
||||
pub(super) fn aim_error(family: Family, yaw: f64, pitch: f64, want_yaw: f64, want_pitch: f64) -> f64 {
|
||||
let yaw_error = angle_diff(yaw, want_yaw).abs();
|
||||
match family {
|
||||
Family::Trainable | Family::Vertical => yaw_error,
|
||||
_ => yaw_error + (want_pitch - pitch).abs(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- firing
|
||||
|
||||
pub(super) const COOLDOWN: u8 = 1;
|
||||
pub(super) const RELOADING: u8 = 2;
|
||||
pub(super) const NO_ROUNDS: u8 = 4;
|
||||
|
||||
/// What keeps a loaded launcher from firing now (aim aside).
|
||||
pub(super) fn missile_blocks(s: &Sam) -> u8 {
|
||||
(if s.cooldown == 0 { 0 } else { COOLDOWN })
|
||||
| (if s.reloading == 0 { 0 } else { RELOADING })
|
||||
| (if s.missiles > 0 { 0 } else { NO_ROUNDS })
|
||||
}
|
||||
|
||||
/// The launcher points near enough: a trainable one only by its training, a turret by its
|
||||
/// whole aim at this target, the vertical ones need nothing.
|
||||
pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<&Drone>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
|
||||
match family {
|
||||
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t.pos - mount)).abs() < 0.15),
|
||||
Family::Turret => aim_id == mt && aim_error < 0.15,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const NOT_AIMED: u8 = 8;
|
||||
pub(super) const AIM_ERROR: u8 = 16;
|
||||
pub(super) const OUT_OF_RANGE: u8 = 32;
|
||||
|
||||
/// What keeps a gun from firing now: the turret on another target, not yet laid, out of range,
|
||||
/// and the gun's own cooldown, reload and shells (`COOLDOWN`, `RELOADING`, `NO_ROUNDS` bits).
|
||||
pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64) -> u8 {
|
||||
(if aim_id == gt { 0 } else { NOT_AIMED })
|
||||
| (if aim_error < 0.1 { 0 } else { AIM_ERROR })
|
||||
| (if dist < range { 0 } else { OUT_OF_RANGE })
|
||||
| (if s.gun_cooldown == 0 { 0 } else { COOLDOWN })
|
||||
| (if s.reloading == 0 { 0 } else { RELOADING })
|
||||
| (if s.bursts > 0 { 0 } else { NO_ROUNDS })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the diagnostic
|
||||
|
||||
/// One condition and whether it holds.
|
||||
pub struct Check {
|
||||
pub ok: bool,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
fn check(ok: bool, text: impl Into<String>) -> Check {
|
||||
Check { ok, text: text.into() }
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Why system `index` fires or doesn't, at its current target or at the most threatening
|
||||
/// drone of a class it engages; computed on demand from the very predicates `step_sams`
|
||||
/// uses. The last line is the verdict.
|
||||
pub fn why(&self, index: usize) -> Vec<Check> {
|
||||
let Some(sam) = self.sams.get(index) else { return vec![check(false, "Нет такой техники")] };
|
||||
let spec = sam.kind.spec();
|
||||
let mut v = vec![check(true, format!("№{} {}: {}", index + 1, spec.name, sam.state.name()))];
|
||||
if !sam.alive() {
|
||||
v.push(check(false, "уничтожен"));
|
||||
return v;
|
||||
}
|
||||
if self.hold {
|
||||
v.push(check(false, "огонь запрещён (/airdef hold off)"));
|
||||
return v;
|
||||
}
|
||||
if sam.moving() {
|
||||
v.push(check(false, format!("{}: не стреляет", sam.state.name().to_lowercase())));
|
||||
return v;
|
||||
}
|
||||
if !sam.kind.armed() {
|
||||
v.push(check(false, "нет оружия (РЛС, склад, РЭБ, прожектор)"));
|
||||
return v;
|
||||
}
|
||||
let logistics = self.logistics();
|
||||
let (missile_on, guns_on) = engagement_counts(&self.sams);
|
||||
let alive = self.drones.iter().filter(|d| d.alive()).count();
|
||||
let seen = self.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)).count();
|
||||
let own = self.drones.iter().filter(|d| d.alive() && sam.own.contains(&d.id)).count();
|
||||
v.push(check(alive > 0, format!("в небе {alive}, на картине {seen} (своя РЛС {own}, остальные по сети)")));
|
||||
let drone_at = |id: u32| self.drones.iter().find(|d| d.id == id);
|
||||
let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| {
|
||||
self.drones
|
||||
.iter()
|
||||
.filter(|d| d.alive() && sam.roles & d.kind.spec().class.bit() != 0)
|
||||
.max_by(|a, b| a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
|
||||
});
|
||||
let Some(t) = subject else {
|
||||
v.push(check(false, format!("нет целей классов [{}]", roles_text(sam.roles))));
|
||||
return v;
|
||||
};
|
||||
let held = sam.target == Some(t.id) || sam.gun_target == Some(t.id);
|
||||
let dist = t.pos.distance(sam.pos);
|
||||
v.push(check(
|
||||
true,
|
||||
format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, crate::panel::distance(dist)),
|
||||
));
|
||||
let blocks = target_blocks(sam.roles, &sam.seen, t);
|
||||
v.push(check(
|
||||
blocks & ROLE == 0,
|
||||
format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)),
|
||||
));
|
||||
let detect = match (sam.own.contains(&t.id), sam.seen.contains(&t.id)) {
|
||||
(true, _) => "обнаружена своей РЛС",
|
||||
(false, true) => "видна только по сети (стволы не стреляют)",
|
||||
_ => "не обнаружена ни своей РЛС, ни сетью",
|
||||
};
|
||||
v.push(check(blocks & UNSEEN == 0, detect));
|
||||
let fit = sam.fitness();
|
||||
v.push(check(fit >= 0.9, format!("состояние {:.0}%: поворот и точность {:.0}%", sam.health * 100.0, fit * 100.0)));
|
||||
// Ammunition and its supply.
|
||||
let mut rounds = String::new();
|
||||
if let Some(load) = &spec.missile {
|
||||
rounds += &format!("ракет {}/{} ", sam.missiles, load.magazine);
|
||||
}
|
||||
if let Some(gun) = &spec.gun {
|
||||
rounds += &format!("очередей {}/{} ", sam.bursts, gun.bursts);
|
||||
}
|
||||
let supply = if sam.reloading > 0 {
|
||||
format!("перезарядка, ещё {:.1} с", sam.reloading as f64 / 20.0)
|
||||
} else if sam.empty {
|
||||
if logistics { "пуст, ждёт машину-заряжающую (ТЗМ) со склада".into() } else { "пуст".into() }
|
||||
} else {
|
||||
"боезапас есть".into()
|
||||
};
|
||||
v.push(check(sam.reloading == 0 && !sam.empty && (sam.missiles > 0 || sam.bursts > 0), format!("{rounds}— {supply}")));
|
||||
let mount = sam.mount().0;
|
||||
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
|
||||
let (_, want_yaw, want_pitch) = aim_wants(mount, t, lead_speed);
|
||||
let err = aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch);
|
||||
let vertical = spec.family == Family::Vertical;
|
||||
let mut fires = false;
|
||||
if let Some(load) = &spec.missile {
|
||||
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, t);
|
||||
v.push(check(
|
||||
reach == 0,
|
||||
format!(
|
||||
"ракеты: дальность {}–{}, цель на {}{}",
|
||||
crate::panel::distance(load.min),
|
||||
crate::panel::distance(load.max),
|
||||
crate::panel::distance(dist),
|
||||
if reach & TOO_CLOSE != 0 { " — слишком близко" } else if reach & TOO_FAR != 0 { " — слишком далеко" } else { "" }
|
||||
),
|
||||
));
|
||||
let free = claimed(t, &missile_on, sam.target);
|
||||
let need = needed(t);
|
||||
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need}{}", if free < need { "" } else { " — другие уже на ней" })));
|
||||
let mb = missile_blocks(sam);
|
||||
let mut why = Vec::new();
|
||||
if mb & COOLDOWN != 0 {
|
||||
why.push(format!("интервал пусков ({:.1} с)", sam.cooldown as f64 / 20.0));
|
||||
}
|
||||
if mb & RELOADING != 0 {
|
||||
why.push("перезарядка".to_string());
|
||||
}
|
||||
if mb & NO_ROUNDS != 0 {
|
||||
why.push("нет ракет".to_string());
|
||||
}
|
||||
v.push(check(mb == 0, if mb == 0 { "пусковая готова".to_string() } else { format!("пусковая не готова: {}", why.join(", ")) }));
|
||||
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(t), t.id, t.id, err);
|
||||
let turn = if vertical { "вертикальный старт" } else { "доворот" };
|
||||
v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" })));
|
||||
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0;
|
||||
}
|
||||
if let Some(gun) = &spec.gun {
|
||||
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
|
||||
let cand = gun_candidate(sam.roles, &sam.seen, &sam.own, gun.track, sam.pos, t);
|
||||
v.push(check(
|
||||
cand,
|
||||
format!(
|
||||
"орудие: сопровождение до {}, цель на {}, других орудий на ней {on_it}{}",
|
||||
crate::panel::distance(gun.track),
|
||||
crate::panel::distance(dist),
|
||||
if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" }
|
||||
),
|
||||
));
|
||||
let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(t.pos), gun.range);
|
||||
let mut why = Vec::new();
|
||||
for (bit, name) in [
|
||||
(AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())),
|
||||
(OUT_OF_RANGE, format!("дальше {}", crate::panel::distance(gun.range))),
|
||||
(COOLDOWN, "пауза между очередями".to_string()),
|
||||
(RELOADING, "перезарядка".to_string()),
|
||||
(NO_ROUNDS, "нет снарядов".to_string()),
|
||||
] {
|
||||
if gb & bit != 0 {
|
||||
why.push(name);
|
||||
}
|
||||
}
|
||||
v.push(check(gb == 0, if gb == 0 { "орудие готово".to_string() } else { format!("орудие не стреляет: {}", why.join(", ")) }));
|
||||
fires |= gb == 0 && cand;
|
||||
}
|
||||
v.push(check(fires, if fires { "итог: готова стрелять" } else { "итог: стрелять сейчас не может" }));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn why_names_the_reason_a_system_does_not_fire() {
|
||||
let mut sim = Sim::new(3);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
sim.sams[0].roles = 0;
|
||||
sim.raid(DroneKind::Shahed, DVec3::new(0.0, 70.0, 0.0), 1, 200.0, &mut out);
|
||||
for _ in 0..60 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
let w = sim.why(0);
|
||||
assert!(w.last().is_some_and(|c| !c.ok), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
|
||||
assert!(w.iter().any(|c| c.text.contains("нет целей классов")));
|
||||
sim.sams[0].roles = 0xFF;
|
||||
assert!(sim.why(0).iter().any(|c| c.text.starts_with("роль")));
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,35 @@ impl WaveConfig {
|
||||
self.approach = p.approach;
|
||||
self.aim = p.aim;
|
||||
}
|
||||
/// The words of a scenario's `wave` line: counts, approach, distance, aim, preset.
|
||||
pub fn text(&self) -> String {
|
||||
let counts: Vec<String> = self.counts.iter().map(u32::to_string).collect();
|
||||
let approach = Approach::ALL.iter().position(|a| *a == self.approach).unwrap_or(0);
|
||||
let aim = Aim::ALL.iter().position(|a| *a == self.aim).unwrap_or(0);
|
||||
format!("{} {approach} {} {aim} {}", counts.join(","), self.dist, self.preset)
|
||||
}
|
||||
/// Reads `text`'s words; what does not parse keeps its value.
|
||||
pub fn parse(&mut self, w: &[&str]) {
|
||||
if let Some(c) = w.first() {
|
||||
for (i, v) in c.split(',').take(KINDS.len()).enumerate() {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.counts[i] = n.min(80);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(a) = w.get(1).and_then(|v| v.parse::<usize>().ok()).and_then(|i| Approach::ALL.get(i)) {
|
||||
self.approach = *a;
|
||||
}
|
||||
if let Some(d) = w.get(2).and_then(|v| v.parse::<f64>().ok()) {
|
||||
self.dist = d.clamp(60.0, 800.0);
|
||||
}
|
||||
if let Some(a) = w.get(3).and_then(|v| v.parse::<usize>().ok()).and_then(|i| Aim::ALL.get(i)) {
|
||||
self.aim = *a;
|
||||
}
|
||||
if let Some(p) = w.get(4).and_then(|v| v.parse::<usize>().ok()) {
|
||||
self.preset = p % PRESETS.len();
|
||||
}
|
||||
}
|
||||
/// The next number for kind `i`, back to 0 after the last.
|
||||
pub fn step(&mut self, i: usize) {
|
||||
let n = self.counts[i];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Fixed headless scenarios on the native simulation: no NaN, the raid ends, no stuck jobs.
|
||||
//! Run: `cargo test --test scenarios` (also `cargo run --release --example sim_run -- --help`).
|
||||
#[path = "../examples/sim_run/harness.rs"]
|
||||
mod harness;
|
||||
|
||||
fn check(sc: harness::Scenario) -> harness::Report {
|
||||
let r = harness::run(&sc);
|
||||
eprintln!("{}", r.text());
|
||||
let problems = r.problems();
|
||||
assert!(problems.is_empty(), "scenario {} failed: {problems:?}\n{}", r.name, r.text());
|
||||
assert!(r.launched > 0, "nothing was launched");
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_smart_raid_on_61_systems() {
|
||||
let r = check(harness::big_smart());
|
||||
assert_eq!(r.units, 61);
|
||||
assert!(r.fired > 0, "air defence never fired");
|
||||
assert!(r.ground_queries > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logistics_trucks_depot_and_raid() {
|
||||
let r = check(harness::logistics());
|
||||
assert!(r.truck_busy_ticks > 0, "the trucks never had a job");
|
||||
assert!(r.fired > 0, "air defence never fired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ew_and_adaptive_enemy_over_three_raids() {
|
||||
let r = check(harness::ew_adaptive());
|
||||
assert_eq!(r.raids_ended, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_result() {
|
||||
let a = harness::run(&harness::logistics());
|
||||
let b = harness::run(&harness::logistics());
|
||||
assert_eq!((a.ticks_run, a.launched, a.kills, a.hits, a.fired, a.ground_queries), (b.ticks_run, b.launched, b.kills, b.hits, b.fired, b.ground_queries));
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# HD-рендер AirDef: устройство, статус, план
|
||||
|
||||
Документ для передачи работы (человеку или агенту). Ведётся по ходу разработки: каждый этап, попавший в
|
||||
`main`, отмечается здесь вместе с коммитом. Если что-то в коде расходится с этим текстом — прав код, а текст
|
||||
надо поправить.
|
||||
|
||||
## Что это
|
||||
|
||||
HD — третий пресет графики мода (`/airdef shader off|on|hd`): отложенное освещение в стиле шейдерпаков
|
||||
(BSL/Complementary-класс), но написанное с нуля внутри нашего форка Horizon (Minecraft 26.3 на Rust/wgpu).
|
||||
Код шейдерпаков (BSL, Complementary, Photon, Iris/OptiFine) **не копируем**: их лицензии запрещают перенос, а в
|
||||
Horizon действует правило clean-room. Пишем по знанию техник (статьи, общеизвестные алгоритмы).
|
||||
|
||||
Пресеты должны оставаться: `off` (ваниль), `on` (пост-эффекты мода), `hd` (этот рендер), позже `rt` (аппаратная
|
||||
трассировка, отдельно).
|
||||
|
||||
## Где код
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` |
|
||||
| Встраивание в кадр: G-буфер-проход, каскады теней, вызовы `hd.light` / `hd.finish`, джиттер, тайминги | `horizon/crates/horizon-client/src/gfx.rs` (`Renderer::render`, ищи `hd_on`, `AirDef fork:`) |
|
||||
| G-буфер террейна: `fs_gbuffer`, `fs_shadow` | `horizon/crates/horizon-client/src/terrain.wgsl` |
|
||||
| G-буфер сущностей и моделей мода: `fs_entity_gbuffer`, `fs_entity_shadow` | `horizon/crates/horizon-client/src/entity.wgsl` |
|
||||
| Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` |
|
||||
| Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` |
|
||||
| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` |
|
||||
|
||||
Форк ведётся в `horizon/` (ветка `airdef`), в репозиторий мода попадает **патчами**:
|
||||
|
||||
```bash
|
||||
cd horizon && git add -A crates && git commit -m "..." # коммит в форке
|
||||
cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat HORIZON_COMMIT)..airdef -o ../horizon-patches
|
||||
```
|
||||
|
||||
`tools/airdef.py fork` сверяет отпечаток `HORIZON_COMMIT` + патчей (`horizon/.git/airdef-stamp`) и пересобирает
|
||||
ветку, если они изменились. После ручной перегенерации патчей у себя обнови отпечаток (см. `fork_stamp` в
|
||||
`tools/airdef.py`), иначе следующий `airdef.py fork` пересоберёт ветку (безопасно: прежняя остаётся в `airdef-prev`).
|
||||
|
||||
## Конвейер кадра (HD включён)
|
||||
|
||||
Все цели в «масштабированном» разрешении мира (`world_post`), HDR — `Rgba16Float`. Глубина камеры — reversed-Z
|
||||
(`Depth32Float`, 1 = близко, 0 = далеко). Позиции везде относительно камеры (camera-relative), мир — в `f64`
|
||||
на CPU.
|
||||
|
||||
1. **Каскады теней** (2 шт., `CASCADES = [40, 176]` блоков полуразмера, `SHADOW_SIZE = 2048`, массив глубины
|
||||
из 2 слоёв). Источник — солнце днём, луна ночью (`light_of`). Матрицы `cascades()` привязаны к текселям
|
||||
(тени не «ползут»). Во время прохода буфер глобалов мира подменяется на глобалы каскада (copy-трюк) и потом
|
||||
возвращается. Рисуются секции террейна в коробке дальнего каскада (`hd_shadow_visible`) и сущности-тенеотбрасыватели
|
||||
(`shadow_casters`: без оверлеев 4/9/10/40 — 40 это светящиеся модели мода).
|
||||
2. **G-буфер** (свой depth): albedo `Rgba8Unorm` (a = эмиссия), нормаль `Rgba16Float` (мировая, из `dpdx/dpdy`),
|
||||
уровни света `Rgba8Unorm` (x = небо, y = блоки — сырые координаты лайтмапа /240). Террейн + дальние LOD-тайлы +
|
||||
сущности. Вода/полупрозрачное в G-буфер **не** попадает (идёт ванильным путём) — см. план.
|
||||
3. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
|
||||
1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл).
|
||||
4. **Освещение** `fs_light` → `lit` (HDR): `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
|
||||
источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6),
|
||||
ореолы вокруг источников. Точечные источники — по тайлам 16×16 (`tile_lists`, буфер `tiles`: на тайл 4 слова —
|
||||
начало/длина списка для поверхностей и для ореолов, потом индексы; переполнение → «все источники»).
|
||||
5. **Пост-проход мода** `lit` → `fx` (`effects.wgsl`: взрывы, дым, вспышки, свечение; при HD его собственная
|
||||
«подсветка огнём» выключена — свет даёт HD).
|
||||
6. **TAA** (в работе, см. статус) — после пост-прохода мода.
|
||||
7. **Bloom** (prefilter + 6 уровней: 13-tap вниз, tent вверх) и **tonemap**: до 0.8 как есть (ванильный вид),
|
||||
выше — мягкий roll-off по ярчайшему каналу; лёгкий грейд (+12% насыщенности, мягкая S-кривая).
|
||||
|
||||
Bind groups кэшируются и пересоздаются только при resize/смене источника (`hd_bind_groups_last_frame()` → 0 в
|
||||
установившемся режиме).
|
||||
|
||||
### Привязки `fs_light` (group 0)
|
||||
|
||||
| # | Что |
|
||||
|---|---|
|
||||
| 0 | `HdFrame` (uniform) |
|
||||
| 1, 2 | цвет и глубина мира (ванильный проход) |
|
||||
| 3, 4, 5 | albedo, нормаль, глубина G-буфера |
|
||||
| 6 | источники света (storage, `HdLight`: pos+radius, color+intensity) |
|
||||
| 7 | уровни света (небо, блоки) |
|
||||
| 8, 9 | массив карт теней, comparison-сэмплер |
|
||||
| 10, 11 | лайтмап, линейный сэмплер |
|
||||
| 12 | тайлы источников (storage) |
|
||||
| 13 | AO |
|
||||
|
||||
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
|
||||
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
|
||||
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке).
|
||||
|
||||
## Как проверять
|
||||
|
||||
```bash
|
||||
cd horizon && cargo build --profile fast -p horizon-client --features mods # без предупреждений
|
||||
cargo fmt -p horizon-client
|
||||
cargo test -p horizon-client --lib hd # naga-разбор всех HD-шейдеров + тест тайлов
|
||||
cd ../airdef-mod && cargo horizon-mod build && cargo test # мод и его шейдер
|
||||
```
|
||||
|
||||
WGSL-ошибки в `hd.rs` всплывают только в рантайме — поэтому каждый новый шейдер добавляй в naga-тест
|
||||
`lighting_shader_parses`.
|
||||
|
||||
Игра офскрин (только одна копия за раз; данные в `run-home/`, мир `Polygon`):
|
||||
|
||||
```bash
|
||||
cd run-home && HORIZON_HOME=$PWD HORIZON_NO_AUDIO=1 ../horizon/target/fast/horizon-ctl --socket $PWD/ctl.sock \
|
||||
launch --world Polygon --offscreen --wait --game ../horizon/target/fast/horizon-client \
|
||||
--resources "$(ls -d "$HOME/Library/Application Support/horizon-launcher/versions"/*/.local/horizon-client/26.3 | sort -V | tail -1)"
|
||||
H="../horizon/target/fast/horizon-ctl --socket $PWD/ctl.sock"
|
||||
$H command "time set 4500"; $H command "tp @s 40 76 40 135 25" # полигон с техникой; z ≥ 60 — море
|
||||
# команды мода — через чат: $H key t tap; $H type "/airdef shader hd"; $H key return tap
|
||||
$H key f1 tap; $H screenshot out.png; $H bench 4; $H quit
|
||||
```
|
||||
|
||||
Тайминги проходов на GPU: `HORIZON_SHADER_PACK_TIMING=1` (лог в `run-home/ctl.log`) или список F3.
|
||||
Ориентиры (M5, 2560×1440, до оптимизации теней): тени 1.3 + 5.4 мс, G-буфер 0.8, AO 2.9, освещение 5.2,
|
||||
bloom 1.1, tonemap 0.2 — кадр ≈ 17 мс GPU.
|
||||
|
||||
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
|
||||
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
|
||||
плоскости YZ.
|
||||
|
||||
## Статус
|
||||
|
||||
| Этап | Состояние | Коммит |
|
||||
|---|---|---|
|
||||
| 1. G-буфер, точечный свет мода в HDR, bloom, тон-кривая; API `set-lights`/`set-lighting` | готово | 0a56f56 (форк) |
|
||||
| 2. Тени солнца/луны, переосвещение мира, лучи, ореолы, туман, грейд | готово | патч 12 |
|
||||
| Кэш bind groups, тени только от тенеотбрасывателей (#12, #13) | готово | патч 13 |
|
||||
| Тайловые списки источников (#10) | готово | патч 14 |
|
||||
| Тайминги HD-проходов (#9), GTAO | готово | патч 15, ed2db85 |
|
||||
| Отсечение взрывов в `effects.wgsl` (#11), порядок дыма (#16), метрика перекрытия свечения (#14) | готово | 67a2a52, ed2db85 |
|
||||
| Удешевление дальнего каскада теней | в работе | — |
|
||||
| TAA (джиттер Halton 2,3, перепроекция по глубине, клиппинг в YCoCg) | в работе | — |
|
||||
|
||||
## План (по порядку) и ТЗ
|
||||
|
||||
Каждый пункт — самостоятельная задача. Перед началом прочитай `hd.rs` целиком.
|
||||
|
||||
1. **TAAU — временной апскейлер** (поверх TAA). Мир и G-буфер рендерятся в `render_scale` (например 0.67),
|
||||
TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
|
||||
джиттера (Catmull-Rom/Lanczos-веса по субпиксельной позиции), клиппинг окрестности как в TAA. Настройка
|
||||
масштаба (`HORIZON_HD_SCALE` или поле в `HdSettings`). Цель: при 0.67 картинка близка к нативной, FPS заметно
|
||||
выше. Альтернатива на Mac — MetalFX через wgpu-hal (нативный код, только Metal) — отдельной опцией потом. DLSS
|
||||
нереален для нашей цели (только NVIDIA через Streamline, нет в wgpu).
|
||||
2. **Вода**: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
|
||||
глубине, откат на небо/туман), преломление (смещение по нормали), затухание по глубине воды (цвет биома),
|
||||
блики солнца (GGX-подобный), френель. Подводный вид не ломать (`frame.underwater`).
|
||||
3. **Небо и атмосфера**: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
|
||||
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
|
||||
(`weather`), закат, Незер/Энд (там — ванильное небо).
|
||||
4. **Объёмные облака** (рэймарч слоя с шумом) + их тени на земле (в карту/маску солнца).
|
||||
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
|
||||
мода), мокрые поверхности и лужи в дождь.
|
||||
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
|
||||
7. **Ветер** листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
|
||||
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
|
||||
9. Опционально: motion blur, DoF (для кинокамеры повторов).
|
||||
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
|
||||
отражения, затем GI. Отдельный пресет, HD остаётся.
|
||||
|
||||
## Правила
|
||||
|
||||
- Ничего не ломать в `off`/`on`; HD выключен — код HD не работает вовсе (`hd = None`).
|
||||
- Без потери качества ради скорости без договорённости (шаги, разрешение, число эффектов).
|
||||
- Каждый новый WGSL — в naga-тест. Каждый новый проход — в тайминги (`PassTiming`).
|
||||
- Комментарии — короткие предложения о том «зачем»; в `gfx.rs` префикс `AirDef fork:`.
|
||||
- Коммиты: в форке, затем патчи; подпись `Co-Authored-By: Claude <модель> <noreply@anthropic.com>`.
|
||||
- Пуш в Gitea с машины без DNS: `ssh://git@100.92.6.40:2222/chexit/airdef.git` (ключ `~/Documents/claude-keys/deploy`).
|
||||
- Обнови этот документ (статус + что изменилось в конвейере) в том же коммите, что и код.
|
||||
+4
-2
@@ -55,7 +55,7 @@ cd ~/Downloads/airdef-macos-arm64
|
||||
|
||||
```powershell
|
||||
rustup target add wasm32-unknown-unknown
|
||||
git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git
|
||||
git clone https://gitea.maoniao.team/chexit/airdef.git
|
||||
cd airdef
|
||||
py tools\airdef.py all # форк Horizon + сборка игры + мод + установка мода
|
||||
py tools\airdef.py play # запуск; мир: py tools\airdef.py play --world "Имя мира"
|
||||
@@ -69,12 +69,14 @@ py tools\airdef.py play # запуск; мир: py tools\airdef.py play
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git
|
||||
git clone https://gitea.maoniao.team/chexit/airdef.git
|
||||
cd airdef
|
||||
python3 tools/airdef.py all
|
||||
python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "Имя мира"
|
||||
```
|
||||
|
||||
Клон по HTTPS не требует ключа. Если у тебя есть SSH-ключ в Gitea, можно и так: `git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git`.
|
||||
|
||||
Шаги по отдельности: `fork` (скачать Horizon и наложить патчи `horizon-patches/`), `game` (собрать игру),
|
||||
`mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||||
Subject: [PATCH 01/12] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/15] Mod API (AirDef fork): world models, client particles,
|
||||
sounds, ground heights
|
||||
|
||||
render.set-models draws block models with an interpolated pose per id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:39:45 +0300
|
||||
Subject: [PATCH 02/12] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/15] cargo-horizon-mod: report particles and sounds
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
||||
Subject: [PATCH 03/12] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/15] Mod API (AirDef fork): full-screen post pass for mods
|
||||
|
||||
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
|
||||
time, 256 vec4 of params, the world's colour and depth) and runs it after
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:09:30 +0300
|
||||
Subject: [PATCH 04/12] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/15] Mod models: draw far models as scaled impostors past
|
||||
the far plane, cull at 4096
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:31:00 +0300
|
||||
Subject: [PATCH 05/12] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/15] Mod cameras: render.set-views draws the world from
|
||||
other points into pictures that HUD view shapes show (API level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:08:21 +0300
|
||||
Subject: [PATCH 06/12] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/15] Mods: more fuel per tick and per event (AirDef plans
|
||||
raids)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:25:29 +0300
|
||||
Subject: [PATCH 07/12] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/15] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:15:11 +0300
|
||||
Subject: [PATCH 08/12] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/15] Mod cameras: distant terrain tiles, fog measured from
|
||||
the camera, no impostors while one is shown
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:25:45 +0300
|
||||
Subject: [PATCH 09/12] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/15] Mods: game.world-id, so mods keep state per world (API
|
||||
level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 12:01:08 +0300
|
||||
Subject: [PATCH 10/12] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/15] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
packing-only tool skips wasmtime
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:18:43 +0300
|
||||
Subject: [PATCH 11/12] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/15] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
|
||||
level 10)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:59:43 +0300
|
||||
Subject: [PATCH 12/12] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/15] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
world relit (sun by face angle and shadow, sky and block light through the
|
||||
lightmap), light shafts and halos, fog over it again, a light colour grade
|
||||
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/15] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 39 ++++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 247 +++++++++++++++++-----------
|
||||
2 files changed, 188 insertions(+), 98 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index e351763fd..f6bbe038c 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -459,6 +459,8 @@ pub struct Renderer {
|
||||
entity_buffer: wgpu::Buffer,
|
||||
entity_capacity: u64,
|
||||
entity_batch: EntityBatch,
|
||||
+ /// AirDef fork: the runs of entity quads (first, end) that cast shadows this frame.
|
||||
+ shadow_casters: Vec<(u32, u32)>,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
lightmap: wgpu::Texture,
|
||||
@@ -2744,6 +2746,7 @@ impl Renderer {
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
entity_batch: EntityBatch::default(),
|
||||
+ shadow_casters: Vec::new(),
|
||||
group,
|
||||
globals,
|
||||
lightmap,
|
||||
@@ -4980,6 +4983,30 @@ impl Renderer {
|
||||
}
|
||||
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
|
||||
if shadow_strength > 0.0 {
|
||||
+ // The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
|
||||
+ // glowing mod geometry), as runs, so those are not vertex-processed twice.
|
||||
+ self.shadow_casters.clear();
|
||||
+ let mut start = None;
|
||||
+ let quads = self.entity_batch.combined.as_chunks::<4>().0;
|
||||
+ for (i, quad) in quads.iter().take(entity_quads as usize).enumerate() {
|
||||
+ let caster = !matches!(quad[0].layer, 4 | 9 | 10 | 40);
|
||||
+ match (caster, start) {
|
||||
+ (true, None) => start = Some(i as u32),
|
||||
+ (false, Some(first)) => {
|
||||
+ self.shadow_casters.push((first, i as u32));
|
||||
+ start = None;
|
||||
+ }
|
||||
+ _ => {}
|
||||
+ }
|
||||
+ }
|
||||
+ if let Some(first) = start {
|
||||
+ self.shadow_casters.push((first, entity_quads));
|
||||
+ }
|
||||
+ // Many tiny runs cost more in draw calls than they save: draw everything.
|
||||
+ if self.shadow_casters.len() > 64 {
|
||||
+ self.shadow_casters.clear();
|
||||
+ self.shadow_casters.push((0, entity_quads));
|
||||
+ }
|
||||
self.queue
|
||||
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
|
||||
for (c, cascade) in hd_cascades.iter().enumerate() {
|
||||
@@ -5021,11 +5048,13 @@ impl Renderer {
|
||||
let instance = hd_shadow_first + k as u32;
|
||||
pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
|
||||
}
|
||||
- if entity_quads > 0 {
|
||||
+ if !self.shadow_casters.is_empty() {
|
||||
pass.set_pipeline(&self.shadow_entity);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
- pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
+ for &(first, end) in &self.shadow_casters {
|
||||
+ pass.draw_indexed(first * 6..end * 6, 0, 0..1);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size);
|
||||
@@ -5586,7 +5615,7 @@ impl Renderer {
|
||||
);
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& hd_on
|
||||
- && let Some(hd) = &self.hd
|
||||
+ && let Some(hd) = &mut self.hd
|
||||
{
|
||||
// AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on
|
||||
// that, then bloom and the tone curve onto the screen.
|
||||
@@ -5607,9 +5636,9 @@ impl Renderer {
|
||||
self.mod_params.1,
|
||||
);
|
||||
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
|
||||
- hd.finish(&self.device, &mut encoder, &hd.fx, &view_target);
|
||||
+ hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
|
||||
}
|
||||
- _ => hd.finish(&self.device, &mut encoder, &hd.lit, &view_target),
|
||||
+ _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
|
||||
}
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& mod_post
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index c36c1795d..fe2634f2c 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -359,6 +359,21 @@ fn target(
|
||||
Target { view }
|
||||
}
|
||||
|
||||
+/// The outside views and sampler the lighting group reads.
|
||||
+type LightKey = (
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::TextureView,
|
||||
+ wgpu::Sampler,
|
||||
+);
|
||||
+
|
||||
+/// What bloom and the tone curve read: the lit world, or the post pass's output.
|
||||
+#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
+pub enum HdSource {
|
||||
+ Lit,
|
||||
+ Fx,
|
||||
+}
|
||||
+
|
||||
pub struct Hd {
|
||||
size: [u32; 2],
|
||||
pub albedo: wgpu::TextureView,
|
||||
@@ -377,6 +392,15 @@ pub struct Hd {
|
||||
pub lit: wgpu::TextureView,
|
||||
pub fx: wgpu::TextureView,
|
||||
bloom: Vec<Target>,
|
||||
+ /// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
|
||||
+ /// lighting and the source-reading ones are keyed by the views they read.
|
||||
+ light_group: Option<(LightKey, wgpu::BindGroup)>,
|
||||
+ prefilter_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
|
||||
+ tone_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
|
||||
+ /// Reading bloom level k - 1 (for the down pass into level k, index k - 1) and level k (for
|
||||
+ /// the up pass onto level k - 1, index k - 1).
|
||||
+ down_groups: Vec<wgpu::BindGroup>,
|
||||
+ up_groups: Vec<wgpu::BindGroup>,
|
||||
frame: wgpu::Buffer,
|
||||
lights: wgpu::Buffer,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -656,6 +680,11 @@ impl Hd {
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
bloom: Vec::new(),
|
||||
+ light_group: None,
|
||||
+ prefilter_group: None,
|
||||
+ tone_group: None,
|
||||
+ down_groups: Vec::new(),
|
||||
+ up_groups: Vec::new(),
|
||||
frame,
|
||||
lights,
|
||||
sampler,
|
||||
@@ -690,6 +719,16 @@ impl Hd {
|
||||
target(device, "HD bloom", s, HDR)
|
||||
})
|
||||
.collect();
|
||||
+ // Every group names a view that was just replaced.
|
||||
+ self.light_group = None;
|
||||
+ self.prefilter_group = None;
|
||||
+ self.tone_group = None;
|
||||
+ self.down_groups = (1..self.bloom.len())
|
||||
+ .map(|k| self.bloom_group(device, &self.bloom[k - 1].view))
|
||||
+ .collect();
|
||||
+ self.up_groups = (1..self.bloom.len())
|
||||
+ .map(|k| self.bloom_group(device, &self.bloom[k].view))
|
||||
+ .collect();
|
||||
}
|
||||
|
||||
/// This frame's camera, sun, fog, lights and settings.
|
||||
@@ -829,73 +868,85 @@ impl Hd {
|
||||
|
||||
/// Lights the world (its colour and depth) into `lit`.
|
||||
pub fn light(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
|
||||
) {
|
||||
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
- label: Some("HD lighting"),
|
||||
- layout: &self.light_layout,
|
||||
- entries: &[
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 0,
|
||||
- resource: self.frame.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 1,
|
||||
- resource: wgpu::BindingResource::TextureView(world.0),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 2,
|
||||
- resource: wgpu::BindingResource::TextureView(world.1),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 3,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.albedo),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 4,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.normal),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 5,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.depth),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 6,
|
||||
- resource: self.lights.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 7,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.levels),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 8,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.shadow_array),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 9,
|
||||
- resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 10,
|
||||
- resource: wgpu::BindingResource::TextureView(lightmap.0),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 11,
|
||||
- resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
- },
|
||||
- ],
|
||||
- });
|
||||
+ let key = (
|
||||
+ world.0.clone(),
|
||||
+ world.1.clone(),
|
||||
+ lightmap.0.clone(),
|
||||
+ lightmap.1.clone(),
|
||||
+ );
|
||||
+ if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ layout: &self.light_layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: self.frame.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.0),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 2,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.1),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 3,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.albedo),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 4,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.normal),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 5,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.depth),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 6,
|
||||
+ resource: self.lights.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 7,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.levels),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 8,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.shadow_array),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 9,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 10,
|
||||
+ resource: wgpu::BindingResource::TextureView(lightmap.0),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 11,
|
||||
+ resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
+ },
|
||||
+ ],
|
||||
+ });
|
||||
+ self.light_group = Some((key, group));
|
||||
+ }
|
||||
+ let Some((_, group)) = &self.light_group else {
|
||||
+ return;
|
||||
+ };
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD lighting",
|
||||
&self.lit,
|
||||
false,
|
||||
&self.light_pipeline,
|
||||
- &group,
|
||||
+ group,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -922,12 +973,53 @@ impl Hd {
|
||||
|
||||
/// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`.
|
||||
pub fn finish(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
- source: &wgpu::TextureView,
|
||||
+ source: HdSource,
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
+ let source = match source {
|
||||
+ HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Fx => self.fx.clone(),
|
||||
+ };
|
||||
+ if self
|
||||
+ .prefilter_group
|
||||
+ .as_ref()
|
||||
+ .is_none_or(|(v, _)| *v != source)
|
||||
+ {
|
||||
+ let group = self.bloom_group(device, &source);
|
||||
+ self.prefilter_group = Some((source.clone(), group));
|
||||
+ }
|
||||
+ if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
|
||||
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("HD tone mapping"),
|
||||
+ layout: &self.tone_layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: self.frame.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::TextureView(&source),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 2,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 3,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ },
|
||||
+ ],
|
||||
+ });
|
||||
+ self.tone_group = Some((source, group));
|
||||
+ }
|
||||
+ let (Some((_, prefilter)), Some((_, tone))) = (&self.prefilter_group, &self.tone_group)
|
||||
+ else {
|
||||
+ return;
|
||||
+ };
|
||||
// Down: the prefilter into the first level, then each level from the one above.
|
||||
Self::draw(
|
||||
encoder,
|
||||
@@ -935,61 +1027,30 @@ impl Hd {
|
||||
&self.bloom[0].view,
|
||||
false,
|
||||
&self.prefilter,
|
||||
- &self.bloom_group(device, source),
|
||||
+ prefilter,
|
||||
);
|
||||
for k in 1..self.bloom.len() {
|
||||
- let g = self.bloom_group(device, &self.bloom[k - 1].view);
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD bloom down",
|
||||
&self.bloom[k].view,
|
||||
false,
|
||||
&self.down,
|
||||
- &g,
|
||||
+ &self.down_groups[k - 1],
|
||||
);
|
||||
}
|
||||
// Up: each level added onto the bigger one.
|
||||
for k in (1..self.bloom.len()).rev() {
|
||||
- let g = self.bloom_group(device, &self.bloom[k].view);
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD bloom up",
|
||||
&self.bloom[k - 1].view,
|
||||
true,
|
||||
&self.up,
|
||||
- &g,
|
||||
+ &self.up_groups[k - 1],
|
||||
);
|
||||
}
|
||||
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
- label: Some("HD tone mapping"),
|
||||
- layout: &self.tone_layout,
|
||||
- entries: &[
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 0,
|
||||
- resource: self.frame.as_entire_binding(),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 1,
|
||||
- resource: wgpu::BindingResource::TextureView(source),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 2,
|
||||
- resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 3,
|
||||
- resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
- },
|
||||
- ],
|
||||
- });
|
||||
- Self::draw(
|
||||
- encoder,
|
||||
- "HD tone mapping",
|
||||
- screen,
|
||||
- false,
|
||||
- &self.tone,
|
||||
- &group,
|
||||
- );
|
||||
+ Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
|
||||
}
|
||||
}
|
||||
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/15] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd.rs | 242 +++++++++++++++++++++++++++-
|
||||
1 file changed, 236 insertions(+), 6 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index fe2634f2c..2776219fc 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -16,6 +16,12 @@ pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// The most lights drawn a frame.
|
||||
pub const MAX_LIGHTS: usize = 512;
|
||||
+/// The side (pixels) of a tile of the screen with its own short list of lights.
|
||||
+const TILE: u32 = 16;
|
||||
+/// The indices all the tile lists may hold together; a tile past it loops over every light.
|
||||
+const TILE_INDICES: usize = 1 << 19;
|
||||
+/// A list length that means "every light": the list overflowed.
|
||||
+const EVERY_LIGHT: u32 = u32::MAX;
|
||||
/// Bloom's mip levels.
|
||||
const BLOOM_LEVELS: usize = 6;
|
||||
|
||||
@@ -55,7 +61,7 @@ struct HdFrame {
|
||||
fog: [f32; 4],
|
||||
/// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
|
||||
ambient: [f32; 4],
|
||||
- /// x: shadows on; y, z: the cascades' half sizes.
|
||||
+ /// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
|
||||
shadow: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -123,6 +129,10 @@ struct Light {
|
||||
@group(0) @binding(9) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(10) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(11) var linear: sampler;
|
||||
+// Per tile of 16x16 pixels: where its surface list and its halo list start in this array and
|
||||
+// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
|
||||
+// every light.
|
||||
+@group(0) @binding(12) var<storage, read> tiles: array<u32>;
|
||||
|
||||
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
@@ -181,6 +191,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sun = frame.sun.xyz;
|
||||
let sun_on = frame.sun.w;
|
||||
let count = u32(frame.params.x);
|
||||
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||||
+ let surface_start = tiles[tile];
|
||||
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
|
||||
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
|
||||
+ let halo_start = tiles[tile + 2u];
|
||||
+ let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
+ let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
var c = scene;
|
||||
let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
|
||||
if surface {
|
||||
@@ -199,8 +216,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
|
||||
// The mods' lights.
|
||||
- for (var i = 0u; i < count; i++) {
|
||||
- let l = lights[i];
|
||||
+ for (var k = 0u; k < surface_count; k++) {
|
||||
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
let to = l.pos.xyz - p;
|
||||
let dist = length(to);
|
||||
if dist >= l.pos.w { continue; }
|
||||
@@ -233,8 +250,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
|
||||
c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
|
||||
}
|
||||
- for (var i = 0u; i < count; i++) {
|
||||
- let l = lights[i];
|
||||
+ for (var k = 0u; k < halo_count; k++) {
|
||||
+ let l = lights[select(tiles[halo_start + k], k, halo_all)];
|
||||
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
|
||||
let t0 = dot(l.pos.xyz, view);
|
||||
let hh = length(l.pos.xyz - view * t0) + 0.5;
|
||||
@@ -403,6 +420,11 @@ pub struct Hd {
|
||||
up_groups: Vec<wgpu::BindGroup>,
|
||||
frame: wgpu::Buffer,
|
||||
lights: wgpu::Buffer,
|
||||
+ /// The tiles' light lists (see `tile_lists`), sized for the screen, and the tiles per row.
|
||||
+ tiles: wgpu::Buffer,
|
||||
+ tiles_x: u32,
|
||||
+ tile_scratch: Vec<Vec<u16>>,
|
||||
+ tile_words: Vec<u32>,
|
||||
sampler: wgpu::Sampler,
|
||||
light_layout: wgpu::BindGroupLayout,
|
||||
light_pipeline: wgpu::RenderPipeline,
|
||||
@@ -414,6 +436,137 @@ pub struct Hd {
|
||||
tone: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
+/// The tiles' header (4 words each) and index lists for a screen of `size`.
|
||||
+fn tiles_buffer(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Buffer {
|
||||
+ let tiles = (size[0].div_ceil(TILE).max(1) * size[1].div_ceil(TILE).max(1)) as usize;
|
||||
+ device.create_buffer(&wgpu::BufferDescriptor {
|
||||
+ label: Some("HD light tiles"),
|
||||
+ size: ((tiles * 4 + TILE_INDICES) * std::mem::size_of::<u32>()) as u64,
|
||||
+ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
+ mapped_at_creation: false,
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+/// Where a camera-relative sphere can show on the screen.
|
||||
+enum Cover {
|
||||
+ /// Nowhere: it is wholly behind the camera.
|
||||
+ Nothing,
|
||||
+ /// Anywhere: it crosses the camera's plane, or holds the camera.
|
||||
+ Everywhere,
|
||||
+ /// Inside this pixel rectangle (min x, min y, max x, max y).
|
||||
+ Rect([f32; 4]),
|
||||
+}
|
||||
+
|
||||
+/// The screen cover of the sphere at `c` of radius `r`: the projection of the 8 corners of the
|
||||
+/// box round it. A box is convex and so is its projection while every corner is in front of the
|
||||
+/// camera, so the rectangle holds the whole sphere. With some corners behind, the projection
|
||||
+/// is not bounded and the cover is the whole screen; with all of them behind, no pixel ray
|
||||
+/// (they all point forward) can meet the sphere.
|
||||
+fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
|
||||
+ if !c.is_finite() || !r.is_finite() {
|
||||
+ return Cover::Everywhere;
|
||||
+ }
|
||||
+ // A little more than the shader's radius, against rounding.
|
||||
+ let r = r * 1.01 + 0.01;
|
||||
+ let (mut lo, mut hi) = (glam::Vec2::splat(f32::MAX), glam::Vec2::splat(f32::MIN));
|
||||
+ let mut front = 0;
|
||||
+ for corner in 0..8 {
|
||||
+ let o = glam::Vec3::new(
|
||||
+ if corner & 1 == 0 { -r } else { r },
|
||||
+ if corner & 2 == 0 { -r } else { r },
|
||||
+ if corner & 4 == 0 { -r } else { r },
|
||||
+ );
|
||||
+ let h = *view_proj * (c + o).extend(1.0);
|
||||
+ if h.w > 0.0 {
|
||||
+ front += 1;
|
||||
+ let ndc = glam::Vec2::new(h.x, h.y) / h.w;
|
||||
+ let px = glam::Vec2::new(
|
||||
+ (ndc.x * 0.5 + 0.5) * size[0] as f32,
|
||||
+ (0.5 - ndc.y * 0.5) * size[1] as f32,
|
||||
+ );
|
||||
+ lo = lo.min(px);
|
||||
+ hi = hi.max(px);
|
||||
+ }
|
||||
+ }
|
||||
+ match front {
|
||||
+ 0 => Cover::Nothing,
|
||||
+ 8 => Cover::Rect([lo.x - 2.0, lo.y - 2.0, hi.x + 2.0, hi.y + 2.0]),
|
||||
+ _ => Cover::Everywhere,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Fills `words` with each 16x16 tile's two lists of the lights that can reach it: header
|
||||
+/// (surface start, surface length, halo start, halo length) per tile, then the indices; a
|
||||
+/// length of `EVERY_LIGHT` stands for all of `lights` (a list past the buffer's room). The
|
||||
+/// surface list holds the lights whose sphere can touch what the tile sees. The halo list
|
||||
+/// holds the lights whose halo can reach a ray of the tile: the shader adds a halo for the
|
||||
+/// view *line* passing within half the radius of the light, so a ray pointing away from a light
|
||||
+/// behind it is lit too, and the mirrored sphere is covered as well. Lights stay in order, so
|
||||
+/// the sums are the ones of the full loop.
|
||||
+fn tile_lists(
|
||||
+ view_proj: &Mat4,
|
||||
+ lights: &[HdLight],
|
||||
+ size: [u32; 2],
|
||||
+ scratch: &mut Vec<Vec<u16>>,
|
||||
+ words: &mut Vec<u32>,
|
||||
+) {
|
||||
+ let tx = size[0].div_ceil(TILE).max(1) as usize;
|
||||
+ let ty = size[1].div_ceil(TILE).max(1) as usize;
|
||||
+ let tiles = tx * ty;
|
||||
+ scratch.resize_with(tiles * 2, Vec::new);
|
||||
+ scratch.truncate(tiles * 2);
|
||||
+ scratch.iter_mut().for_each(Vec::clear);
|
||||
+ let mut add = |list: usize, area: Cover, light: u16| match area {
|
||||
+ Cover::Nothing => {}
|
||||
+ Cover::Everywhere => {
|
||||
+ for t in 0..tiles {
|
||||
+ scratch[t * 2 + list].push(light);
|
||||
+ }
|
||||
+ }
|
||||
+ Cover::Rect([x0, y0, x1, y1]) => {
|
||||
+ let tile = TILE as f32;
|
||||
+ if x1 < 0.0 || y1 < 0.0 || x0 >= size[0] as f32 || y0 >= size[1] as f32 {
|
||||
+ return;
|
||||
+ }
|
||||
+ let from = |v: f32, n: usize| ((v / tile).floor().max(0.0) as usize).min(n - 1);
|
||||
+ for y in from(y0, ty)..=from(y1, ty) {
|
||||
+ for x in from(x0, tx)..=from(x1, tx) {
|
||||
+ scratch[(y * tx + x) * 2 + list].push(light);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ };
|
||||
+ for (i, l) in lights.iter().enumerate() {
|
||||
+ let c = glam::Vec3::new(l.pos[0], l.pos[1], l.pos[2]);
|
||||
+ add(0, cover(view_proj, c, l.pos[3], size), i as u16);
|
||||
+ // The shader's halo reach: the line within `radius / 2 - 0.5` of the light.
|
||||
+ let reach = l.pos[3] * 0.5 - 0.5;
|
||||
+ if reach > 0.0 || !reach.is_finite() {
|
||||
+ add(1, cover(view_proj, c, reach, size), i as u16);
|
||||
+ add(1, cover(view_proj, -c, reach, size), i as u16);
|
||||
+ }
|
||||
+ }
|
||||
+ words.clear();
|
||||
+ words.resize(tiles * 4, 0);
|
||||
+ let mut room = TILE_INDICES;
|
||||
+ for t in 0..tiles {
|
||||
+ for list in 0..2 {
|
||||
+ let ids = &mut scratch[t * 2 + list];
|
||||
+ // Both the sphere and its mirror can name a light: once is enough.
|
||||
+ ids.dedup();
|
||||
+ let at = t * 4 + list * 2;
|
||||
+ if ids.len() > room {
|
||||
+ words[at + 1] = EVERY_LIGHT;
|
||||
+ } else {
|
||||
+ room -= ids.len();
|
||||
+ words[at] = words.len() as u32;
|
||||
+ words[at + 1] = ids.len() as u32;
|
||||
+ words.extend(ids.iter().map(|&i| u32::from(i)));
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
@@ -527,6 +680,11 @@ impl Hd {
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
FLOAT,
|
||||
SAMPLER,
|
||||
+ wgpu::BindingType::Buffer {
|
||||
+ ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
+ has_dynamic_offset: false,
|
||||
+ min_binding_size: None,
|
||||
+ },
|
||||
]),
|
||||
});
|
||||
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -687,6 +845,10 @@ impl Hd {
|
||||
up_groups: Vec::new(),
|
||||
frame,
|
||||
lights,
|
||||
+ tiles: tiles_buffer(device, [1, 1]),
|
||||
+ tiles_x: 1,
|
||||
+ tile_scratch: Vec::new(),
|
||||
+ tile_words: Vec::new(),
|
||||
sampler,
|
||||
light_layout,
|
||||
light_pipeline,
|
||||
@@ -713,6 +875,8 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ self.tiles = tiles_buffer(device, size);
|
||||
+ self.tiles_x = size[0].div_ceil(TILE).max(1);
|
||||
self.bloom = (0..BLOOM_LEVELS)
|
||||
.map(|k| {
|
||||
let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
|
||||
@@ -743,6 +907,15 @@ impl Hd {
|
||||
if n > 0 {
|
||||
queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n]));
|
||||
}
|
||||
+ // Written every frame, empty too: the shader reads the counts, not `params.x`.
|
||||
+ tile_lists(
|
||||
+ &scene.view_proj,
|
||||
+ &lights[..n],
|
||||
+ self.size,
|
||||
+ &mut self.tile_scratch,
|
||||
+ &mut self.tile_words,
|
||||
+ );
|
||||
+ queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
// The sun by day, the moon (opposite) by night, faded out round the horizon.
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
@@ -773,7 +946,7 @@ impl Hd {
|
||||
if strength > 0.0 { 1.0 } else { 0.0 },
|
||||
CASCADES[0],
|
||||
CASCADES[1],
|
||||
- 0.0,
|
||||
+ self.tiles_x as f32,
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame));
|
||||
@@ -933,6 +1106,10 @@ impl Hd {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::Sampler(lightmap.1),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 12,
|
||||
+ resource: self.tiles.as_entire_binding(),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -1102,3 +1279,56 @@ pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] {
|
||||
(projection * view).as_mat4()
|
||||
})
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn lighting_shader_parses() {
|
||||
+ for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
|
||||
+ let source = format!("{COMMON}{body}");
|
||||
+ let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
+ panic!("{name}: {}", e.emit_to_string(&source));
|
||||
+ });
|
||||
+ naga::valid::Validator::new(
|
||||
+ naga::valid::ValidationFlags::all(),
|
||||
+ naga::valid::Capabilities::all(),
|
||||
+ )
|
||||
+ .validate(&module)
|
||||
+ .unwrap_or_else(|e| panic!("{name}: {e:?}"));
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn tile_lists_keep_lights_that_reach_a_tile() {
|
||||
+ let size = [320, 160];
|
||||
+ let proj = Mat4::perspective_rh(1.0, 2.0, 0.05, 1000.0);
|
||||
+ let light = |x: f32, y: f32, z: f32, r: f32| HdLight {
|
||||
+ pos: [x, y, z, r],
|
||||
+ color: [1.0; 4],
|
||||
+ };
|
||||
+ let lights = [
|
||||
+ // Dead ahead and small; far to the side; behind the camera; holding the camera.
|
||||
+ light(0.0, 0.0, -20.0, 4.0),
|
||||
+ light(30.0, 0.0, -5.0, 4.0),
|
||||
+ light(0.0, 0.0, 10.0, 8.0),
|
||||
+ light(1.0, 0.0, 0.0, 6.0),
|
||||
+ ];
|
||||
+ let (mut scratch, mut words) = (Vec::new(), Vec::new());
|
||||
+ tile_lists(&proj, &lights, size, &mut scratch, &mut words);
|
||||
+ let list = |tile: usize, which: usize| -> Vec<u32> {
|
||||
+ let (start, len) = (words[tile * 4 + which * 2], words[tile * 4 + which * 2 + 1]);
|
||||
+ words[start as usize..(start + len) as usize].to_vec()
|
||||
+ };
|
||||
+ // The centre tile sees the first light and the one at the camera, in both lists.
|
||||
+ let centre = 5 * 20 + 10;
|
||||
+ assert_eq!(list(centre, 0), vec![0, 3]);
|
||||
+ assert!(list(centre, 1).contains(&0) && list(centre, 1).contains(&3));
|
||||
+ // A far corner sees only the one at the camera.
|
||||
+ assert_eq!(list(0, 0), vec![3]);
|
||||
+ // The light behind the camera shows in the halo list where the view points away.
|
||||
+ assert!(list(centre, 1).contains(&2));
|
||||
+ assert!(!list(0, 0).contains(&2));
|
||||
+ }
|
||||
+}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:43:36 +0300
|
||||
Subject: [PATCH 15/15] HD: GPU time per pass in the F3 timing list,
|
||||
half-resolution GTAO with a depth-aware blur
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 23 +-
|
||||
crates/horizon-client/src/gfx/hd.rs | 430 +++++++++++++++++++++++++++-
|
||||
2 files changed, 443 insertions(+), 10 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index f6bbe038c..e14429b03 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -3725,9 +3725,20 @@ impl Renderer {
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
- timer
|
||||
+ let mut times = timer
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
- .unwrap_or_default()
|
||||
+ .unwrap_or_default();
|
||||
+ // AirDef fork: the HD lighting's passes (shadows, G-buffer, occlusion, lighting, bloom,
|
||||
+ // tone curve) while it is on.
|
||||
+ if let Some(hd) = &self.hd {
|
||||
+ times.extend(hd.pass_times());
|
||||
+ }
|
||||
+ times
|
||||
+ }
|
||||
+
|
||||
+ /// AirDef fork: bind groups the HD lighting made in the last frame (0 in a steady state).
|
||||
+ pub fn hd_bind_groups_last_frame(&self) -> Option<u64> {
|
||||
+ self.hd.as_ref().map(|hd| hd.groups_last_frame)
|
||||
}
|
||||
/// The dimension the shader pack was last set for.
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
@@ -4981,6 +4992,8 @@ impl Renderer {
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
+ hd.set_timing(device, &self.queue, self.pass_timing);
|
||||
+ hd.timing_begin(device, &self.queue);
|
||||
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
|
||||
if shadow_strength > 0.0 {
|
||||
// The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
|
||||
@@ -5090,6 +5103,9 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
+ drop(pass);
|
||||
+ // Contact shadows from the G-buffer's depth and normals, for the lighting.
|
||||
+ hd.ao(&mut encoder);
|
||||
} else if !hd_on {
|
||||
self.hd = None;
|
||||
}
|
||||
@@ -5831,6 +5847,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
+ if hd_on && let Some(hd) = &mut self.hd {
|
||||
+ hd.timing_submitted(&self.queue);
|
||||
+ }
|
||||
if let Some((path, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 2776219fc..d3944e97b 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -5,7 +5,9 @@
|
||||
//! into a high-dynamic-range target; the mod's post pass runs on that; then
|
||||
//! bright parts bloom and a tone curve brings it to the screen — vanilla's
|
||||
//! range (up to about 0.8) left as it is, only what is brighter rolled off.
|
||||
+use super::pack_timing::PassTiming;
|
||||
use glam::Mat4;
|
||||
+use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// The G-buffer's formats.
|
||||
pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
@@ -24,6 +26,25 @@ const TILE_INDICES: usize = 1 << 19;
|
||||
const EVERY_LIGHT: u32 = u32::MAX;
|
||||
/// Bloom's mip levels.
|
||||
const BLOOM_LEVELS: usize = 6;
|
||||
+/// The ambient occlusion's targets (half resolution): occlusion and the distance it was taken at.
|
||||
+pub const AO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float;
|
||||
+/// The occlusion's default strength (`Hd::ao_strength`; 0 turns it off).
|
||||
+pub const AO_STRENGTH: f32 = 1.0;
|
||||
+/// The timed passes of bloom, by level (each is timed on its own; the report sums them).
|
||||
+const BLOOM_NAMES: [&str; 2 * BLOOM_LEVELS] = [
|
||||
+ "hd bloom#0",
|
||||
+ "hd bloom#1",
|
||||
+ "hd bloom#2",
|
||||
+ "hd bloom#3",
|
||||
+ "hd bloom#4",
|
||||
+ "hd bloom#5",
|
||||
+ "hd bloom#6",
|
||||
+ "hd bloom#7",
|
||||
+ "hd bloom#8",
|
||||
+ "hd bloom#9",
|
||||
+ "hd bloom#10",
|
||||
+ "hd bloom#11",
|
||||
+];
|
||||
|
||||
/// A light for this frame, relative to the camera.
|
||||
#[repr(C)]
|
||||
@@ -59,7 +80,8 @@ struct HdFrame {
|
||||
fog_color: [f32; 4],
|
||||
/// Environmental start, end; render-distance start, end.
|
||||
fog: [f32; 4],
|
||||
- /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
|
||||
+ /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos);
|
||||
+ /// w: the ambient occlusion's strength (0 off).
|
||||
ambient: [f32; 4],
|
||||
/// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
|
||||
shadow: [f32; 4],
|
||||
@@ -133,6 +155,34 @@ struct Light {
|
||||
// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
|
||||
// every light.
|
||||
@group(0) @binding(12) var<storage, read> tiles: array<u32>;
|
||||
+// Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at.
|
||||
+@group(0) @binding(13) var ao_tex: texture_2d<f32>;
|
||||
+
|
||||
+// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels
|
||||
+// weighted by closeness and by how near their distance is, so it doesn't bleed across edges.
|
||||
+fn ao_at(px: vec2<i32>, dist: f32) -> f32 {
|
||||
+ let size = vec2<i32>(textureDimensions(ao_tex));
|
||||
+ let f = vec2<f32>(px) * 0.5;
|
||||
+ let base = vec2<i32>(floor(f));
|
||||
+ let t = f - floor(f);
|
||||
+ var sum = 0.0;
|
||||
+ var total = 0.0;
|
||||
+ for (var j = 0; j < 2; j++) {
|
||||
+ for (var i = 0; i < 2; i++) {
|
||||
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
|
||||
+ let s = textureLoad(ao_tex, q, 0).xy;
|
||||
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
|
||||
+ let dz = (s.y - dist) / (0.03 * dist + 0.1);
|
||||
+ let w = select(0.0, bw / (1.0 + dz * dz * dz * dz), s.y > 0.0);
|
||||
+ sum += s.x * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ if total < 0.0001 {
|
||||
+ return textureLoad(ao_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0).x;
|
||||
+ }
|
||||
+ return sum / total;
|
||||
+}
|
||||
|
||||
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
@@ -214,7 +264,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
|
||||
// direct light by the face's angle.
|
||||
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
- var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
|
||||
+ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
|
||||
+ // the mods' lights by half, not on the sun or on emission.
|
||||
+ var occlusion = 1.0;
|
||||
+ if frame.ambient.w > 0.0 { occlusion = ao_at(px, length(p)); }
|
||||
+ let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
|
||||
+ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
|
||||
+ var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||||
// The mods' lights.
|
||||
for (var k = 0u; k < surface_count; k++) {
|
||||
let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
@@ -224,7 +280,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
|
||||
let falloff = l.color.w / (dist * dist + 1.0) * window;
|
||||
let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
|
||||
- light += l.color.rgb * falloff * k;
|
||||
+ light += l.color.rgb * falloff * k * ao_lights;
|
||||
}
|
||||
c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
|
||||
// Vanilla's fog over it again.
|
||||
@@ -264,6 +320,131 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
"#;
|
||||
|
||||
+const AO: &str = r#"
|
||||
+const AO_RADIUS: f32 = 1.8;
|
||||
+const AO_SLICES: i32 = 3;
|
||||
+const AO_STEPS: i32 = 5;
|
||||
+const PI: f32 = 3.14159265;
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var depth_tex: texture_depth_2d;
|
||||
+@group(0) @binding(2) var normal_tex: texture_2d<f32>;
|
||||
+
|
||||
+// Interleaved gradient noise, shifted per frame (params.w) and per use.
|
||||
+fn noise(px: vec2<f32>, shift: vec2<f32>) -> f32 {
|
||||
+ let p = px + shift + vec2<f32>(5.588238 * frame.params.w);
|
||||
+ return fract(52.9829189 * fract(dot(p, vec2<f32>(0.06711056, 0.00583715))));
|
||||
+}
|
||||
+
|
||||
+// The camera-relative position of a (continuous) full-resolution pixel at depth z.
|
||||
+fn position_at(px: vec2<f32>, z: f32) -> vec3<f32> {
|
||||
+ let uv = px * frame.resolution.zw;
|
||||
+ let h = frame.inverse_view_proj * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, z, 1.0);
|
||||
+ return h.xyz / h.w;
|
||||
+}
|
||||
+
|
||||
+// Ground-truth ambient occlusion (horizon based): per slice through the view ray, the highest
|
||||
+// horizon on either side, integrated against the normal's cosine lobe. Half resolution: one
|
||||
+// pixel of each 2x2.
|
||||
+@fragment
|
||||
+fn fs_ao(in: FullOut) -> @location(0) vec2<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(depth_tex));
|
||||
+ let ic = min(vec2<i32>(in.position.xy) * 2, size - 1);
|
||||
+ let z = textureLoad(depth_tex, ic, 0);
|
||||
+ if z <= 0.0 { return vec2<f32>(1.0, 0.0); }
|
||||
+ let nrm = textureLoad(normal_tex, ic, 0).xyz;
|
||||
+ let c = vec2<f32>(ic) + 0.5;
|
||||
+ let p = position_at(c, z);
|
||||
+ let dist = length(p);
|
||||
+ if dot(nrm, nrm) < 0.01 { return vec2<f32>(1.0, dist); }
|
||||
+ let n = normalize(nrm);
|
||||
+ let v = -p / dist;
|
||||
+ // Blocks per pixel here (the plane of equal depth is square to the view), so how far in
|
||||
+ // pixels the radius reaches.
|
||||
+ let per_px = max(length(position_at(c + vec2<f32>(4.0, 0.0), z) - p) / 4.0, 0.00001);
|
||||
+ let reach = min(AO_RADIUS / per_px, 56.0);
|
||||
+ if reach < 2.0 { return vec2<f32>(1.0, dist); }
|
||||
+ let radius = reach * per_px;
|
||||
+ let rot = noise(in.position.xy, vec2<f32>(0.0));
|
||||
+ let jitter = noise(in.position.xy, vec2<f32>(37.0, 17.0));
|
||||
+ var visibility = 0.0;
|
||||
+ for (var s = 0; s < AO_SLICES; s++) {
|
||||
+ let phi = (f32(s) + rot) / f32(AO_SLICES) * PI;
|
||||
+ let d2 = vec2<f32>(cos(phi), sin(phi));
|
||||
+ // The slice's direction in space, square to the view ray, and its plane's axis.
|
||||
+ let q = position_at(c + d2 * 4.0, z) - p;
|
||||
+ let dir = normalize(q - v * dot(q, v));
|
||||
+ let axis = cross(dir, v);
|
||||
+ let projected = n - axis * dot(n, axis);
|
||||
+ let plen = length(projected);
|
||||
+ if plen < 0.0001 { visibility += 1.0; continue; }
|
||||
+ let cos_n = clamp(dot(projected, v) / plen, -1.0, 1.0);
|
||||
+ let nang = select(-1.0, 1.0, dot(projected, dir) >= 0.0) * acos(cos_n);
|
||||
+ var c_neg = -1.0;
|
||||
+ var c_pos = -1.0;
|
||||
+ for (var i = 0; i < AO_STEPS; i++) {
|
||||
+ let t = (f32(i) + jitter) / f32(AO_STEPS);
|
||||
+ let r = 1.5 + (reach - 1.5) * t * t;
|
||||
+ for (var side = 0; side < 2; side++) {
|
||||
+ let sg = select(-1.0, 1.0, side == 1);
|
||||
+ let ip = vec2<i32>(floor(c + d2 * (r * sg)));
|
||||
+ if any(ip < vec2<i32>(0)) || any(ip >= size) { continue; }
|
||||
+ let sz = textureLoad(depth_tex, ip, 0);
|
||||
+ if sz <= 0.0 { continue; }
|
||||
+ let to = position_at(vec2<f32>(ip) + 0.5, sz) - p;
|
||||
+ let len = length(to);
|
||||
+ // Points on (or under) the surface's own plane don't occlude it.
|
||||
+ if dot(to, n) < 0.1 * len { continue; }
|
||||
+ // Far points count less, to nothing at the radius.
|
||||
+ let fall = clamp((len - radius * 0.6) / (radius * 0.4), 0.0, 1.0);
|
||||
+ let ch = mix(dot(to / len, v), -1.0, fall);
|
||||
+ if side == 1 { c_pos = max(c_pos, ch); } else { c_neg = max(c_neg, ch); }
|
||||
+ }
|
||||
+ }
|
||||
+ var h_neg = -acos(clamp(c_neg, -1.0, 1.0));
|
||||
+ var h_pos = acos(clamp(c_pos, -1.0, 1.0));
|
||||
+ h_neg = nang + max(h_neg - nang, -0.5 * PI);
|
||||
+ h_pos = nang + min(h_pos - nang, 0.5 * PI);
|
||||
+ let sn = sin(nang);
|
||||
+ let arc_neg = cos_n + 2.0 * h_neg * sn - cos(2.0 * h_neg - nang);
|
||||
+ let arc_pos = cos_n + 2.0 * h_pos * sn - cos(2.0 * h_pos - nang);
|
||||
+ visibility += plen * 0.25 * (arc_neg + arc_pos);
|
||||
+ }
|
||||
+ let ao = clamp(visibility / f32(AO_SLICES), 0.0, 1.0);
|
||||
+ // Far away it is too coarse to show: fade out.
|
||||
+ return vec2<f32>(mix(1.0, ao, 1.0 - smoothstep(40.0, 96.0, dist)), dist);
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+const AO_BLUR: &str = r#"
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var raw_ao: texture_2d<f32>;
|
||||
+
|
||||
+// A 5x5 blur that only takes texels at about the same distance (so not across an edge).
|
||||
+@fragment
|
||||
+fn fs_ao_blur(in: FullOut) -> @location(0) vec2<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(raw_ao));
|
||||
+ let ip = vec2<i32>(in.position.xy);
|
||||
+ let centre = textureLoad(raw_ao, ip, 0).xy;
|
||||
+ if centre.y <= 0.0 { return centre; }
|
||||
+ let tolerance = 0.03 * centre.y + 0.1;
|
||||
+ var sum = 0.0;
|
||||
+ var total = 0.0;
|
||||
+ for (var y = -2; y <= 2; y++) {
|
||||
+ for (var x = -2; x <= 2; x++) {
|
||||
+ let q = ip + vec2<i32>(x, y);
|
||||
+ if any(q < vec2<i32>(0)) || any(q >= size) { continue; }
|
||||
+ let s = textureLoad(raw_ao, q, 0).xy;
|
||||
+ if s.y <= 0.0 { continue; }
|
||||
+ let dz = (s.y - centre.y) / tolerance;
|
||||
+ let w = 1.0 / (1.0 + dz * dz * dz * dz);
|
||||
+ sum += s.x * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ return vec2<f32>(sum / max(total, 0.0001), centre.y);
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
const BLOOM: &str = r#"
|
||||
@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
@group(0) @binding(1) var source: texture_2d<f32>;
|
||||
@@ -434,6 +615,23 @@ pub struct Hd {
|
||||
up: wgpu::RenderPipeline,
|
||||
tone_layout: wgpu::BindGroupLayout,
|
||||
tone: wgpu::RenderPipeline,
|
||||
+ /// The ambient occlusion: its strength in the lighting, the half-resolution targets (raw, then
|
||||
+ /// blurred), their passes and bind groups (made at `resize`).
|
||||
+ pub ao_strength: f32,
|
||||
+ ao_raw: wgpu::TextureView,
|
||||
+ ao_blurred: wgpu::TextureView,
|
||||
+ ao_layout: wgpu::BindGroupLayout,
|
||||
+ ao_blur_layout: wgpu::BindGroupLayout,
|
||||
+ ao_pipeline: wgpu::RenderPipeline,
|
||||
+ ao_blur_pipeline: wgpu::RenderPipeline,
|
||||
+ ao_group: Option<wgpu::BindGroup>,
|
||||
+ ao_blur_group: Option<wgpu::BindGroup>,
|
||||
+ /// GPU time of the HD passes while diagnostics ask for it (`set_timing`).
|
||||
+ timing: Option<PassTiming>,
|
||||
+ /// Bind groups made so far, and in the last frame (a steady state makes none).
|
||||
+ groups_made: AtomicU64,
|
||||
+ groups_seen: u64,
|
||||
+ pub groups_last_frame: u64,
|
||||
}
|
||||
|
||||
/// The tiles' header (4 words each) and index lists for a screen of `size`.
|
||||
@@ -567,6 +765,22 @@ fn tile_lists(
|
||||
}
|
||||
}
|
||||
|
||||
+/// The timestamps of the pass `name` while HD passes are timed.
|
||||
+fn stamp<'a>(
|
||||
+ timing: &'a mut Option<PassTiming>,
|
||||
+ name: &str,
|
||||
+) -> Option<wgpu::RenderPassTimestampWrites<'a>> {
|
||||
+ timing.as_mut()?.writes(name)
|
||||
+}
|
||||
+
|
||||
+/// `HORIZON_HD_AO=0` turns the ambient occlusion off, another number sets its strength.
|
||||
+fn ao_strength_default() -> f32 {
|
||||
+ std::env::var("HORIZON_HD_AO")
|
||||
+ .ok()
|
||||
+ .and_then(|v| v.parse().ok())
|
||||
+ .unwrap_or(AO_STRENGTH)
|
||||
+}
|
||||
+
|
||||
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
@@ -685,8 +899,17 @@ impl Hd {
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
+ UNFILTERED,
|
||||
]),
|
||||
});
|
||||
+ let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD ambient occlusion"),
|
||||
+ entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
|
||||
+ });
|
||||
+ let ao_blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD ambient occlusion blur"),
|
||||
+ entries: &entries(&[UNIFORM, UNFILTERED]),
|
||||
+ });
|
||||
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD bloom"),
|
||||
entries: &entries(&[UNIFORM, FLOAT, SAMPLER]),
|
||||
@@ -698,6 +921,26 @@ impl Hd {
|
||||
let light = module(device, "HD lighting", LIGHT);
|
||||
let bloom = module(device, "HD bloom", BLOOM);
|
||||
let tone = module(device, "HD tone mapping", TONEMAP);
|
||||
+ let ao = module(device, "HD ambient occlusion", AO);
|
||||
+ let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
|
||||
+ let ao_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD ambient occlusion",
|
||||
+ &ao_layout,
|
||||
+ &ao,
|
||||
+ "fs_ao",
|
||||
+ AO_FORMAT,
|
||||
+ None,
|
||||
+ );
|
||||
+ let ao_blur_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &ao_blur_layout,
|
||||
+ &ao_blur,
|
||||
+ "fs_ao_blur",
|
||||
+ AO_FORMAT,
|
||||
+ None,
|
||||
+ );
|
||||
let add = wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
@@ -858,6 +1101,19 @@ impl Hd {
|
||||
up,
|
||||
tone_layout,
|
||||
tone,
|
||||
+ ao_strength: ao_strength_default(),
|
||||
+ ao_raw: target(device, "HD AO", [1, 1], AO_FORMAT).view,
|
||||
+ ao_blurred: target(device, "HD AO blurred", [1, 1], AO_FORMAT).view,
|
||||
+ ao_layout,
|
||||
+ ao_blur_layout,
|
||||
+ ao_pipeline,
|
||||
+ ao_blur_pipeline,
|
||||
+ ao_group: None,
|
||||
+ ao_blur_group: None,
|
||||
+ timing: None,
|
||||
+ groups_made: AtomicU64::new(0),
|
||||
+ groups_seen: 0,
|
||||
+ groups_last_frame: 0,
|
||||
};
|
||||
hd.resize(device, size);
|
||||
hd
|
||||
@@ -875,6 +1131,28 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ let half = [size[0].div_ceil(2), size[1].div_ceil(2)];
|
||||
+ self.ao_raw = target(device, "HD AO", half, AO_FORMAT).view;
|
||||
+ self.ao_blurred = target(device, "HD AO blurred", half, AO_FORMAT).view;
|
||||
+ self.ao_group = Some(self.make_group(
|
||||
+ device,
|
||||
+ "HD ambient occlusion",
|
||||
+ &self.ao_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&self.depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.normal),
|
||||
+ ],
|
||||
+ ));
|
||||
+ self.ao_blur_group = Some(self.make_group(
|
||||
+ device,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &self.ao_blur_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&self.ao_raw),
|
||||
+ ],
|
||||
+ ));
|
||||
self.tiles = tiles_buffer(device, size);
|
||||
self.tiles_x = size[0].div_ceil(TILE).max(1);
|
||||
self.bloom = (0..BLOOM_LEVELS)
|
||||
@@ -917,6 +1195,15 @@ impl Hd {
|
||||
);
|
||||
queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
+ let made = self.groups_made.load(Ordering::Relaxed);
|
||||
+ self.groups_last_frame = made - self.groups_seen;
|
||||
+ self.groups_seen = made;
|
||||
+ if PassTiming::logging() && self.frames.is_multiple_of(240) {
|
||||
+ log::info!(
|
||||
+ "HD bind groups made in the last frame: {} ({made} in all)",
|
||||
+ self.groups_last_frame
|
||||
+ );
|
||||
+ }
|
||||
// The sun by day, the moon (opposite) by night, faded out round the horizon.
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
let cascades = cascades(scene.camera, dir);
|
||||
@@ -941,7 +1228,12 @@ impl Hd {
|
||||
fog_color: scene.fog_color,
|
||||
fog: scene.fog,
|
||||
// In the shade by day a surface keeps half its sky light; at night almost all.
|
||||
- ambient: [0.5 + 0.45 * night, 1.0, 0.0018 + scene.rain * 0.006, 0.0],
|
||||
+ ambient: [
|
||||
+ 0.5 + 0.45 * night,
|
||||
+ 1.0,
|
||||
+ 0.0018 + scene.rain * 0.006,
|
||||
+ self.ao_strength.max(0.0),
|
||||
+ ],
|
||||
shadow: [
|
||||
if strength > 0.0 { 1.0 } else { 0.0 },
|
||||
CASCADES[0],
|
||||
@@ -954,13 +1246,14 @@ impl Hd {
|
||||
|
||||
/// The shadow pass of cascade `c`: its depth layer cleared, nothing else.
|
||||
pub fn shadow_pass<'e>(
|
||||
- &self,
|
||||
+ &mut self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
c: usize,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HD shadow cascade"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, ["hd shadow 0", "hd shadow 1"][c.min(1)]),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.shadow_layers[c],
|
||||
@@ -976,7 +1269,10 @@ impl Hd {
|
||||
}
|
||||
|
||||
/// The G-buffer pass's attachments: albedo and normal cleared, its own depth.
|
||||
- pub fn gbuffer_pass<'e>(&self, encoder: &'e mut wgpu::CommandEncoder) -> wgpu::RenderPass<'e> {
|
||||
+ pub fn gbuffer_pass<'e>(
|
||||
+ &mut self,
|
||||
+ encoder: &'e mut wgpu::CommandEncoder,
|
||||
+ ) -> wgpu::RenderPass<'e> {
|
||||
let clear = |view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -991,6 +1287,7 @@ impl Hd {
|
||||
encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HD G-buffer"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd g-buffer"),
|
||||
color_attachments: &[
|
||||
clear(&self.albedo),
|
||||
clear(&self.normal),
|
||||
@@ -1016,9 +1313,11 @@ impl Hd {
|
||||
load: bool,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
+ timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
||||
) {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
+ timestamp_writes,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: to,
|
||||
resolve_target: None,
|
||||
@@ -1054,6 +1353,7 @@ impl Hd {
|
||||
lightmap.1.clone(),
|
||||
);
|
||||
if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD lighting"),
|
||||
layout: &self.light_layout,
|
||||
@@ -1110,6 +1410,10 @@ impl Hd {
|
||||
binding: 12,
|
||||
resource: self.tiles.as_entire_binding(),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 13,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.ao_blurred),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -1124,10 +1428,36 @@ impl Hd {
|
||||
false,
|
||||
&self.light_pipeline,
|
||||
group,
|
||||
+ stamp(&mut self.timing, "hd lighting"),
|
||||
);
|
||||
}
|
||||
|
||||
+ /// A bind group of consecutive bindings 0.., counted (`groups_last_frame`).
|
||||
+ fn make_group(
|
||||
+ &self,
|
||||
+ device: &wgpu::Device,
|
||||
+ label: &str,
|
||||
+ layout: &wgpu::BindGroupLayout,
|
||||
+ resources: &[wgpu::BindingResource<'_>],
|
||||
+ ) -> wgpu::BindGroup {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
+ let entries: Vec<_> = resources
|
||||
+ .iter()
|
||||
+ .enumerate()
|
||||
+ .map(|(i, r)| wgpu::BindGroupEntry {
|
||||
+ binding: i as u32,
|
||||
+ resource: r.clone(),
|
||||
+ })
|
||||
+ .collect();
|
||||
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some(label),
|
||||
+ layout,
|
||||
+ entries: &entries,
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD bloom"),
|
||||
layout: &self.bloom_layout,
|
||||
@@ -1169,6 +1499,7 @@ impl Hd {
|
||||
self.prefilter_group = Some((source.clone(), group));
|
||||
}
|
||||
if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
|
||||
+ self.groups_made.fetch_add(1, Ordering::Relaxed);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HD tone mapping"),
|
||||
layout: &self.tone_layout,
|
||||
@@ -1205,6 +1536,7 @@ impl Hd {
|
||||
false,
|
||||
&self.prefilter,
|
||||
prefilter,
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[0]),
|
||||
);
|
||||
for k in 1..self.bloom.len() {
|
||||
Self::draw(
|
||||
@@ -1214,6 +1546,7 @@ impl Hd {
|
||||
false,
|
||||
&self.down,
|
||||
&self.down_groups[k - 1],
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[k]),
|
||||
);
|
||||
}
|
||||
// Up: each level added onto the bigger one.
|
||||
@@ -1225,9 +1558,84 @@ impl Hd {
|
||||
true,
|
||||
&self.up,
|
||||
&self.up_groups[k - 1],
|
||||
+ stamp(&mut self.timing, BLOOM_NAMES[BLOOM_LEVELS + k - 1]),
|
||||
);
|
||||
}
|
||||
- Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD tone mapping",
|
||||
+ screen,
|
||||
+ false,
|
||||
+ &self.tone,
|
||||
+ tone,
|
||||
+ stamp(&mut self.timing, "hd tonemap"),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ /// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
|
||||
+ /// its blur. Run after the G-buffer pass; nothing when the strength is 0.
|
||||
+ pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
+ if self.ao_strength <= 0.0 {
|
||||
+ return;
|
||||
+ }
|
||||
+ let (Some(raw), Some(blur)) = (&self.ao_group, &self.ao_blur_group) else {
|
||||
+ return;
|
||||
+ };
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD ambient occlusion",
|
||||
+ &self.ao_raw,
|
||||
+ false,
|
||||
+ &self.ao_pipeline,
|
||||
+ raw,
|
||||
+ stamp(&mut self.timing, "hd ao"),
|
||||
+ );
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD ambient occlusion blur",
|
||||
+ &self.ao_blurred,
|
||||
+ false,
|
||||
+ &self.ao_blur_pipeline,
|
||||
+ blur,
|
||||
+ stamp(&mut self.timing, "hd ao blur"),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ /// Times the HD passes on the GPU while `on` (and the device can), else not.
|
||||
+ pub fn set_timing(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, on: bool) {
|
||||
+ if on != self.timing.is_some() {
|
||||
+ self.timing = on.then(|| PassTiming::new(device, queue)).flatten();
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// At the start of the frame's passes, before the first of them.
|
||||
+ pub fn timing_begin(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
|
||||
+ if let Some(t) = &mut self.timing {
|
||||
+ t.begin(device, queue);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// After the frame is submitted.
|
||||
+ pub fn timing_submitted(&mut self, queue: &wgpu::Queue) {
|
||||
+ if let Some(t) = &mut self.timing {
|
||||
+ t.submitted(queue);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// Each HD pass's median GPU time in milliseconds (bloom's passes summed into one).
|
||||
+ pub fn pass_times(&self) -> Vec<(String, f32)> {
|
||||
+ let Some(t) = &self.timing else {
|
||||
+ return Vec::new();
|
||||
+ };
|
||||
+ let mut out: Vec<(String, f32)> = Vec::new();
|
||||
+ for (name, ms) in t.medians() {
|
||||
+ let name = name.split('#').next().unwrap_or(&name).to_owned();
|
||||
+ match out.iter_mut().find(|o| o.0 == name) {
|
||||
+ Some(o) => o.1 += ms,
|
||||
+ None => out.push((name, ms)),
|
||||
+ }
|
||||
+ }
|
||||
+ out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1286,7 +1694,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn lighting_shader_parses() {
|
||||
- for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
|
||||
+ for (name, body) in [
|
||||
+ ("light", LIGHT),
|
||||
+ ("bloom", BLOOM),
|
||||
+ ("tonemap", TONEMAP),
|
||||
+ ("ao", AO),
|
||||
+ ("ao blur", AO_BLUR),
|
||||
+ ] {
|
||||
let source = format!("{COMMON}{body}");
|
||||
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
panic!("{name}: {}", e.emit_to_string(&source));
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
+28
-3
@@ -12,6 +12,7 @@
|
||||
Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного).
|
||||
"""
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
@@ -51,21 +52,45 @@ def cargo() -> str:
|
||||
return found
|
||||
|
||||
|
||||
def fork_stamp(commit: str, patches) -> str:
|
||||
"""Что должно стоять в horizon/: коммит апстрима и содержимое патчей."""
|
||||
h = hashlib.sha256(commit.encode())
|
||||
for p in patches:
|
||||
h.update(p.name.encode())
|
||||
h.update(p.read_bytes())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def fork():
|
||||
"""horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/."""
|
||||
"""horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/.
|
||||
|
||||
Ветка airdef пересобирается, когда коммит или патчи изменились (после обновления репозитория);
|
||||
прежняя остаётся в ветке airdef-prev."""
|
||||
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
|
||||
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
|
||||
stamp = fork_stamp(commit, patches)
|
||||
stamp_file = FORK / ".git" / "airdef-stamp"
|
||||
if not (FORK / ".git").exists():
|
||||
run(["git", "clone", "--filter=blob:none", UPSTREAM, FORK])
|
||||
branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip()
|
||||
if branch == "airdef":
|
||||
print("horizon/: ветка airdef уже есть")
|
||||
if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp:
|
||||
print("horizon/: ветка airdef на нужных коммите и патчах")
|
||||
return
|
||||
dirty = subprocess.run(["git", "-C", FORK, "status", "--porcelain", "--untracked-files=no"],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
if dirty:
|
||||
sys.exit("horizon/: есть незакоммиченные изменения, а движок надо обновить. Закоммить или убери их и запусти снова.")
|
||||
exists = subprocess.run(["git", "-C", FORK, "rev-parse", "--verify", "-q", "refs/heads/airdef"],
|
||||
capture_output=True).returncode == 0
|
||||
if exists:
|
||||
print("horizon/: коммит или патчи изменились, ветка airdef пересобирается (прежняя: airdef-prev)")
|
||||
run(["git", "-C", FORK, "branch", "-f", "airdef-prev", "airdef"])
|
||||
run(["git", "-C", FORK, "fetch", "--quiet", "origin", commit])
|
||||
run(["git", "-C", FORK, "checkout", "-B", "airdef", commit])
|
||||
if patches:
|
||||
run(["git", "-C", FORK, "-c", "user.name=airdef", "-c", "user.email=airdef@localhost",
|
||||
"am", "--3way", *patches])
|
||||
stamp_file.write_text(stamp + "\n")
|
||||
|
||||
|
||||
def game():
|
||||
|
||||
@@ -227,6 +227,7 @@ TEXTURES = {
|
||||
"lamp": lambda: gradient((255, 255, 240), (255, 230, 170)),
|
||||
"beam": lambda: gradient((255, 250, 225), (255, 240, 200)),
|
||||
"antenna": lambda: noise((70, 72, 70), 3, 65),
|
||||
"nose_dark": lambda: noise((58, 60, 62), 3, 66),
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +342,30 @@ def pac3():
|
||||
return p
|
||||
|
||||
|
||||
def irist_missile():
|
||||
"""IRIS-T SLM: тонкая белая ракета, две пары малых канардов у носа и четыре хвостовых киля."""
|
||||
p = taper("white", -16, -10, 1, 3, 1, 3, 3)
|
||||
p += [el((6.5, 6.5, -10), (9.5, 9.5, 24), "white")]
|
||||
p += both(el((3, 7.6, -9), (6.5, 8.4, -5), "missile_grey")) # канарды у носа
|
||||
p += [el((7.6, 3, -9), (8.4, 6.5, -5), "missile_grey"), el((7.6, 9.5, -9), (8.4, 13, -5), "missile_grey")]
|
||||
p += both(el((2, 7.6, 18), (6.5, 8.4, 26), "white")) # хвостовые киля
|
||||
p += [el((7.6, 2, 18), (8.4, 6.5, 26), "white"), el((7.6, 9.5, 18), (8.4, 14, 26), "white")]
|
||||
p += [el((7, 7, 24), (9, 9, 28), "nozzle")]
|
||||
return p
|
||||
|
||||
|
||||
def amraam_missile():
|
||||
"""AMRAAM: белое тело с тёмным носом, четыре крыла на середине корпуса и четыре хвостовых киля."""
|
||||
p = taper("nose_dark", -16, -9, 1, 3, 1, 3, 3)
|
||||
p += [el((6.5, 6.5, -9), (9.5, 9.5, 26), "white")]
|
||||
p += both(el((1, 7.6, 2), (6.5, 8.4, 9), "white")) # среднекорпусные крылья
|
||||
p += [el((7.6, 1, 2), (8.4, 6.5, 9), "white"), el((7.6, 9.5, 2), (8.4, 15, 9), "white")]
|
||||
p += both(el((4, 7.6, 19), (6.5, 8.4, 25), "white")) # хвостовые киля
|
||||
p += [el((7.6, 4, 19), (8.4, 6.5, 25), "white"), el((7.6, 9.5, 19), (8.4, 12, 25), "white")]
|
||||
p += [el((7, 7, 26), (9, 9, 28), "nozzle")]
|
||||
return p
|
||||
|
||||
|
||||
def flame():
|
||||
"""Факел двигателя: светящаяся «капля», хвост — на юг (+z)."""
|
||||
return taper("flame", -8, 26, 5, 1, 5, 1, 8)
|
||||
@@ -750,6 +775,8 @@ MODELS = {
|
||||
"geran3": (geran3, "Герань-3"),
|
||||
"geran3_flame": (geran3_flame, "Факел"),
|
||||
"pac3": (pac3, "PAC-3 MSE"),
|
||||
"irist_missile": (irist_missile, "IRIS-T SLM"),
|
||||
"amraam_missile": (amraam_missile, "AMRAAM"),
|
||||
"flame": (flame, "Факел"),
|
||||
"patriot_launcher": (patriot_launcher, "ПУ M903"),
|
||||
"patriot_truck": (patriot_truck, "Тягач"),
|
||||
|
||||
Reference in New Issue
Block a user