Compare commits

...

6 Commits

Author SHA1 Message Date
chexit 766b292aec Continuous engine sound on drones and missiles, live and in replays, through persistent sound sources (#95, fork patch 37)
build / mod (push) Successful in 3m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:10:24 +03:00
chexit ab6b61db59 Acoustics porting plan from Sound Physics Remastered 453654e (#96 stage 1)
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:54:01 +03:00
chexit 4d4e1682f5 Windows build: patches and the fork keep LF, packer rebuilt when the fork changes; install notes
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:49:57 +03:00
chexit 545d4777e8 Kill outcomes: falling hull, breakup or air detonation by weapon and target, recorded for replays (#97)
build / mod (push) Successful in 3m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:47:03 +03:00
chexit f16bc2387f /airdef command tree with typed arguments, live scenario names and generated help (#98, fork patch 36)
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:43:11 +03:00
chexit 9bc94fa8aa Raids launch from real sites: 1, 2 or 4-5 sides, stock and launch queue, land first, then nearer land, then barges on water (#100)
build / mod (push) Successful in 3m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:36:43 +03:00
65 changed files with 11232 additions and 122 deletions
+1
View File
@@ -4,3 +4,4 @@
*.png binary
*.jpg binary
*.hmod binary
*.patch -text
+6
View File
@@ -221,6 +221,12 @@ API мода `render.set-lights` (до 512, плавно интерполиру
У каждого объекта свой звук и своя дальность слышимости (`attenuation_distance` в `sounds.json`):
- **двигатели:** двухтактный мопед шахеда (220 м), турбина Герани-3, электромотор Ланцета, тяжёлый рёв крылатых ракет (320 м),
ракетный рёв Х-22, Циркона и перехватчиков, нарастающий визг падающей баллистики и её ударная волна (700–900 м);
Двигатели дронов и ракет — **постоянные источники звука** (API 20, `render.sound-sources`, #95): один источник на
объект идёт за его настоящей позицией, зажигается с нарастанием, при сбитии «захлёбывается» (тон падает, громкость
гаснет за секунду) и замолкает вместе с объектом; в повторе и экспорте источники берутся из записанных траекторий и
времени повтора (перемотка и смена плана пересоздают их). Одновременно звучат 24 ближайших и громких, остальные
«виртуальные». Взрывы, пуски и выстрелы остались разовыми, с задержкой по скорости звука. Баллистика (визг при
падении) тоже разовая. Зацикливается устойчивая часть файла (начало и затухание в конце обрезаны), стык — 50 мс.
- **пуски:** хлопок и рвущийся рёв Patriot, свистящий старт Панциря, глухой газовый выброс и затем огромный рёв С-400,
у С-500 ещё ниже и дольше (до 1 км);
- **пушки:** тяжёлые 35-мм очереди Гепарда и частые 30-мм Панциря;
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/barge"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/launch_ramp"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/launcher_truck"
}
}
}
+3
View File
@@ -55,6 +55,9 @@
"block.airdef.radar_truck": "Шасси РЛС",
"block.airdef.depot": "Склад боеприпасов",
"block.airdef.tzm_truck": "Транспортно-заряжающая машина",
"block.airdef.barge": "Баржа",
"block.airdef.launch_ramp": "Рампа запуска дронов",
"block.airdef.launcher_truck": "Пусковая машина ракет",
"block.airdef.ew_truck": "Станция РЭБ",
"block.airdef.searchlight": "Прожектор",
"block.airdef.searchlight_head": "Голова прожектора",
+3
View File
@@ -55,6 +55,9 @@
"block.airdef.radar_truck": "Шасси РЛС",
"block.airdef.depot": "Склад боеприпасов",
"block.airdef.tzm_truck": "Транспортно-заряжающая машина",
"block.airdef.barge": "Баржа",
"block.airdef.launch_ramp": "Рампа запуска дронов",
"block.airdef.launcher_truck": "Пусковая машина ракет",
"block.airdef.ew_truck": "Станция РЭБ",
"block.airdef.searchlight": "Прожектор",
"block.airdef.searchlight_head": "Голова прожектора",
@@ -0,0 +1,896 @@
{
"ambientocclusion": false,
"textures": {
"dark_metal": "airdef:block/dark_metal",
"metal": "airdef:block/metal",
"ru_khaki_s31": "airdef:block/ru_khaki_s31",
"ru_khaki_s33": "airdef:block/ru_khaki_s33",
"particle": "airdef:block/dark_metal"
},
"elements": [
{
"from": [
-16.0,
0.0,
-16.0
],
"to": [
32.0,
3.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
3.0,
-16.0
],
"to": [
32.0,
4.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#metal"
}
}
},
{
"from": [
-15.0,
3.0,
-15.0
],
"to": [
31.0,
4.4,
31.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"south": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"east": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"west": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#ru_khaki_s33"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#ru_khaki_s33"
}
}
},
{
"from": [
-16.0,
4.0,
-16.0
],
"to": [
-14.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
30.0,
4.0,
-16.0
],
"to": [
32.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
4.0,
30.0
],
"to": [
32.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
4.0,
-16.0
],
"to": [
32.0,
6.0,
-14.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-12.0,
4.4,
-10.0
],
"to": [
-9.0,
6.5,
-7.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
-10.0
],
"to": [
28.0,
6.5,
-7.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
-12.0,
4.4,
8.0
],
"to": [
-9.0,
6.5,
11.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
8.0
],
"to": [
28.0,
6.5,
11.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
-12.0,
4.4,
26.0
],
"to": [
-9.0,
6.5,
29.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
26.0
],
"to": [
28.0,
6.5,
29.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
}
]
}
@@ -0,0 +1,709 @@
{
"ambientocclusion": false,
"textures": {
"dark_metal": "airdef:block/dark_metal",
"metal": "airdef:block/metal",
"ru_khaki": "airdef:block/ru_khaki",
"particle": "airdef:block/dark_metal"
},
"elements": [
{
"from": [
1.0,
0.0,
-6.0
],
"to": [
15.0,
2.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
14.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
14.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
14.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
14.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
6.0,
2.0,
0.0
],
"to": [
10.0,
4.0,
26.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
4.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
4.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
4.0,
16
],
"texture": "#metal"
}
}
},
{
"from": [
3.0,
9.0,
-14.0
],
"to": [
5.0,
11.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
}
},
"rotation": {
"angle": 22.5,
"axis": "x",
"origin": [
4.0,
10.0,
8.0
]
}
},
{
"from": [
11.0,
9.0,
-14.0
],
"to": [
13.0,
11.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
}
},
"rotation": {
"angle": 22.5,
"axis": "x",
"origin": [
12.0,
10.0,
8.0
]
}
},
{
"from": [
3.0,
0.0,
-1.0
],
"to": [
5.0,
12.0,
1.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
11.0,
0.0,
-1.0
],
"to": [
13.0,
12.0,
1.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
3.0,
0.0,
17.0
],
"to": [
5.0,
6.0,
19.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
11.0,
0.0,
17.0
],
"to": [
13.0,
6.0,
19.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
4.0,
10.0,
24.0
],
"to": [
12.0,
14.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
8.0,
4.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
8.0,
4.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
6.0,
4.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
6.0,
4.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
8.0,
6.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
8.0,
6.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
1.0,
2.0,
28.0
],
"to": [
15.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"south": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"east": {
"uv": [
0,
0,
4.0,
4.0
],
"texture": "#ru_khaki"
},
"west": {
"uv": [
0,
0,
4.0,
4.0
],
"texture": "#ru_khaki"
},
"up": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"down": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
}
}
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+275
View File
@@ -0,0 +1,275 @@
//! The `/airdef` command tree: the one description the game's chat box (colours, errors,
//! Tab completion), the help line and the tests read (#98). The handler in `lib.rs` still
//! splits its line itself; this tree decides what reaches it.
use crate::sim::{DroneKind, SamKind};
use horizon_mod_sdk::tree::{Node, Opt, Tree, Value};
/// The suggestion list of the saved scenarios' names (`command::set_suggestions`).
pub const SCENARIOS: &str = "scenarios";
fn on_off() -> Value {
Value::choice(["on", "off"])
}
/// `/airdef ...` as `lib.rs` accepts it.
pub fn tree() -> Tree {
let wave_kinds: Vec<&str> = DroneKind::ALL.iter().map(|k| k.spec().key).chain(["mix"]).collect();
let deploy_kinds: Vec<&str> = SamKind::ALL.iter().map(|k| k.key()).collect();
let armed: Vec<&str> = SamKind::ARMED.iter().map(|k| k.key()).chain(["all"]).collect();
let toggle = |word: &str| Node::lit(word).then(Node::arg("on/off", on_off()).runs());
Tree::new("airdef").runs().with([
Node::lit("menu").runs(),
Node::lit("monitor").runs(),
Node::lit("grid").runs(),
Node::lit("replay").runs().with([
Node::lit("map").runs(),
Node::lit("full").runs(),
Node::lit("reel").runs(),
Node::lit("film").runs().with([
Node::arg("режим", Value::choice(["best", "all"])).runs(),
Node::arg("N", Value::int_from(1)).runs(),
]),
// The words after `export` come in any order: a name, best/all/N, key=value options.
Node::lit("export").runs().then(
Node::options(
"параметры",
vec![
Opt::new("fps", Value::int_from(1)),
Opt::new("size", Value::word()),
Opt::new("crf", Value::int_from(0)),
Opt::new("sec", Value::Number(Some(0.0), None)),
Opt::flag("noaudio"),
],
Some(vec!["best".into(), "all".into()]),
)
.runs(),
),
Node::lit("stop").runs(),
]),
Node::lit("scenario").with([
Node::lit("save").then(
Node::arg("имя", Value::word()).runs().then(Node::arg("fresh", Value::choice(["fresh"])).runs()),
),
Node::lit("load").then(
Node::arg("имя", Value::names(SCENARIOS)).runs().then(Node::arg("force", Value::choice(["force"])).runs()),
),
Node::lit("list").runs(),
Node::lit("delete").then(Node::arg("имя", Value::names(SCENARIOS)).runs()),
]),
Node::lit("why").runs().then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("raid").runs(),
Node::lit("repair").runs(),
Node::lit("import").runs(),
toggle("ew"),
Node::lit("flare").runs(),
Node::lit("move").then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("target").then(Node::lit("here").runs()),
Node::lit("give").runs(),
Node::lit("deploy").then(Node::arg("комплекс", Value::choice(deploy_kinds)).runs()),
Node::lit("wave").then(
Node::arg("тип", Value::choice(wave_kinds)).runs().then(
Node::arg("кол-во", Value::int(1, 80)).runs().then(Node::arg("дальность", Value::number(60.0, 800.0)).runs()),
),
),
Node::lit("clear").runs(),
Node::lit("targets").then(Node::lit("clear").runs()),
toggle("damage"),
toggle("truth"),
Node::lit("shader").then(Node::arg("режим", Value::choice(["on", "off", "hd"])).runs()),
toggle("hold"),
Node::lit("roles").runs().then(
Node::arg("комплекс", Value::choice(armed)).then(
Node::arg(
"классы",
Value::Word(["default", "all", "drone", "jet", "cruise", "supersonic", "ballistic"].map(String::from).to_vec()),
)
.runs(),
),
),
Node::lit("stats").runs(),
])
}
/// The help line, from the same tree.
pub fn help() -> String {
tree().usage()
}
/// The scenario names after `name` is saved (once, in the order saved).
pub fn names_after_save(mut all: Vec<String>, name: &str) -> Vec<String> {
if !all.iter().any(|n| n == name) {
all.push(name.to_string());
}
all
}
/// The scenario names after `name` is deleted.
pub fn names_after_delete(mut all: Vec<String>, name: &str) -> Vec<String> {
all.retain(|n| n != name);
all
}
/// Registers the tree with the game, and the scenario names it suggests.
pub fn register(scenarios: &[String]) {
horizon_mod_sdk::command::set_suggestions(SCENARIOS, scenarios);
if let Err(e) = tree().register() {
horizon_mod_sdk::warn(&format!("airdef: command tree refused: {e}"));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(line: &str) {
if let Err(e) = tree().parse(line) {
panic!("`{line}` should parse: {e}");
}
}
fn bad_at(line: &str) -> usize {
match tree().parse(line) {
Ok(_) => panic!("`{line}` should not parse"),
Err(e) => e.at,
}
}
fn complete(line: &str, scenarios: &[&str]) -> Vec<String> {
let names: Vec<String> = scenarios.iter().map(|s| s.to_string()).collect();
tree().complete(line, &|_| names.clone())
}
/// Every branch of the old `HELP` line and of the handler's match parses, with its
/// options.
#[test]
fn every_branch_of_the_handler_parses() {
for line in [
"", "menu", "monitor", "grid", "give", "raid", "repair", "import", "flare", "clear", "stats", "roles",
"replay", "replay map", "replay full", "replay reel", "replay film", "replay film best", "replay film all",
"replay film 3", "replay export", "replay export mine", "replay export best", "replay export all", "replay export 3",
"replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop",
"scenario save a", "scenario save a fresh", "scenario load a", "scenario load a force", "scenario list",
"scenario delete a", "why", "why 2", "ew on", "ew off", "move 4", "target here", "targets clear",
"deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800",
"wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on",
"roles all default", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
] {
ok(line);
}
}
#[test]
fn the_trees_kinds_are_the_enums_own() {
for k in SamKind::ALL {
ok(&format!("deploy {}", k.key()));
}
for k in DroneKind::ALL {
ok(&format!("wave {}", k.spec().key));
}
for k in SamKind::ARMED {
ok(&format!("roles {} all", k.key()));
}
}
#[test]
fn numbers_and_words_outside_what_the_handler_takes_are_refused_where_they_are() {
assert_eq!(bad_at("wave shahed 0"), 12);
assert_eq!(bad_at("wave shahed 81"), 12);
assert_eq!(bad_at("wave shahed 5 59"), 14);
assert_eq!(bad_at("wave shahed 5 801"), 14);
assert_eq!(bad_at("wave shahed x"), 12);
assert_eq!(bad_at("wave scud"), 5);
assert_eq!(bad_at("wave"), 4);
assert_eq!(bad_at("deploy tank"), 7);
assert_eq!(bad_at("deploy"), 6);
assert_eq!(bad_at("ew maybe"), 3);
assert_eq!(bad_at("shader ultra"), 7);
assert_eq!(bad_at("why 0"), 4);
assert_eq!(bad_at("why x"), 4);
assert_eq!(bad_at("move"), 4);
assert_eq!(bad_at("move 0"), 5);
assert_eq!(bad_at("scenario"), 8);
assert_eq!(bad_at("scenario load"), 13);
assert_eq!(bad_at("scenario wipe"), 9);
assert_eq!(bad_at("scenario save a keep"), 16);
assert_eq!(bad_at("target there"), 7);
assert_eq!(bad_at("roles tank all"), 6);
assert_eq!(bad_at("roles patriot"), 13);
assert_eq!(bad_at("replay sideways"), 7);
assert_eq!(bad_at("replay film best now"), 17);
assert_eq!(bad_at("nothing"), 0);
assert_eq!(bad_at("menu 1"), 5);
}
/// The key=value options of `replay export` stay what `start_export` reads.
#[test]
fn export_options_are_checked_by_key_and_value() {
assert_eq!(bad_at("replay export fps=0"), 14);
assert_eq!(bad_at("replay export fps=x"), 14);
assert_eq!(bad_at("replay export fps"), 14);
assert_eq!(bad_at("replay export noaudio=1"), 14);
assert_eq!(bad_at("replay export fps=30 fps=60"), 21);
assert_eq!(bad_at("replay export sec=-1"), 14);
assert_eq!(bad_at("replay export crf=-2"), 14);
assert_eq!(bad_at("replay export size="), 14);
let p = tree().parse("replay export mine fps=30 noaudio").unwrap();
assert_eq!(p.get("параметры"), Some("mine fps=30 noaudio"));
}
#[test]
fn completion_offers_the_next_word_only() {
let top = complete("", &[]);
for w in ["menu", "monitor", "grid", "replay", "scenario", "why", "raid", "repair", "import", "ew", "flare", "move",
"target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "roles", "stats"] {
assert!(top.contains(&w.to_string()), "{w} in {top:?}");
}
assert_eq!(top.len(), 24);
assert_eq!(complete("rep", &[]), ["repair", "replay"]);
assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]);
assert_eq!(complete("replay film ", &[]), ["all", "best"]);
assert_eq!(complete("scenario ", &[]), ["delete", "list", "load", "save"]);
assert_eq!(complete("wave ", &[]).len(), 14);
assert!(complete("wave s", &[]).contains(&"shahed".to_string()));
assert_eq!(complete("shader ", &[]), ["hd", "off", "on"]);
assert_eq!(complete("replay export ", &[]), ["all", "best", "crf=", "fps=", "noaudio", "sec=", "size="]);
assert_eq!(complete("replay export fps=30 no", &[]), ["noaudio"]);
assert!(complete("menu ", &[]).is_empty());
assert!(complete("wave nothing ", &[]).is_empty());
}
/// The names follow the data: after a save the new name is offered, after a delete it is gone.
#[test]
fn scenario_names_follow_a_save_and_a_delete() {
let mut all: Vec<String> = Vec::new();
let names = |all: &[String]| -> Vec<String> { tree().complete("scenario load ", &|_| all.to_vec()) };
assert!(names(&all).is_empty());
all = names_after_save(all, "base");
all = names_after_save(all, "night");
all = names_after_save(all, "base");
assert_eq!(names(&all), ["base", "night"]);
let of = |all: &[String], line: &str| tree().complete(line, &|_| all.to_vec());
assert_eq!(of(&all, "scenario delete n"), ["night"]);
all = names_after_delete(all, "base");
assert_eq!(names(&all), ["night"]);
assert_eq!(of(&all, "scenario delete "), ["night"]);
all = names_after_delete(all, "night");
assert!(names(&all).is_empty());
}
#[test]
fn the_help_line_is_the_trees() {
let help = help();
assert!(help.starts_with("/airdef menu | monitor | grid | replay "), "{help}");
assert!(help.contains("scenario save <имя> [fresh]"), "{help}");
assert!(help.contains("wave <тип> [<кол-во> [<дальность>]]"), "{help}");
assert!(help.ends_with("| stats"), "{help}");
assert!(help.contains("export [fps=… size=… crf=… sec=… noaudio]"), "{help}");
assert!(help.contains("deploy <комплекс>"), "{help}");
}
/// The nodes handed to the game are a tree the game accepts.
#[test]
fn the_nodes_are_flat_and_ordered() {
let nodes = tree().nodes();
assert!(nodes.len() < 512, "{}", nodes.len());
assert!(nodes.iter().enumerate().all(|(i, n)| n.children.iter().all(|&c| c as usize > i)));
}
}
+330
View File
@@ -0,0 +1,330 @@
//! Engines as persistent sound sources (#95): a drone's or a missile's engine is one source that
//! follows the entity, instead of a one-shot repeated every second or two.
//!
//! The simulation (live) and the replay (from a recording's tracks and the replay's time) each
//! *declare* the engines sounding this tick; [`Engines::step`] turns that into the changes of
//! `render.sound-sources`: a start with an attack when an engine appears (ignition), an update
//! every tick, a stop with a release when it is gone (burnout, the entity removed), and a fall
//! of pitch and gain over a second when it is damaged (a drone shot down). A declaration missing
//! for a tick or two (a step the simulation skipped) does not stop the sound; a *cut* (a replay
//! seek, a new shot) stops every engine and starts them again under new ids, so nothing glides
//! across the jump.
use glam::DVec3;
use std::collections::{BTreeMap, BTreeSet};
/// Ticks an engine may go undeclared before it is stopped.
const GRACE: u32 = 3;
/// Ticks a damaged engine takes to die: its pitch falls and it fades.
const DYING: u32 = 20;
/// The most engines that sound at once (the nearest ones; the game's budget picks the audible).
const MAX: usize = 128;
/// Doppler applied to engines. The kinds already differ in pitch, and the fight's speed must not
/// change what is heard, so it stays off; the game bounds it to 12% anyway.
pub const DOPPLER: f32 = 0.0;
/// An engine that sounds this tick.
#[derive(Clone, Debug, PartialEq)]
pub struct Decl {
/// The entity: a drone's id, a missile's with a high bit, a replay track's with another.
pub key: u32,
pub event: &'static str,
pub at: DVec3,
/// Blocks a second.
pub vel: DVec3,
pub gain: f32,
pub pitch: f32,
/// Shot down: the engine dies away.
pub damaged: bool,
}
/// What a source is asked to be.
#[derive(Clone, Debug, PartialEq)]
pub struct Voice {
pub event: &'static str,
pub at: DVec3,
pub vel: DVec3,
pub gain: f32,
pub pitch: f32,
pub attack: f32,
pub release: f32,
pub loop_from: f32,
pub loop_to: f32,
}
/// One change of `render.sound-sources`.
#[derive(Clone, Debug, PartialEq)]
pub enum Op {
Start(u32, Voice),
Update(u32, Voice),
Stop(u32),
}
/// The steady part of each engine sound, seconds into the file: the sounds fade in at their start
/// and die away at their end (synthesis, reverb tail, fade: tools/gen_sounds.py), so looping a
/// whole file leaves a gap; these were found as `audio::tests::a_loop_of_the_steady_part_of_the_
/// engine_sounds_has_no_hole` finds them (after the fade-in, before the sound begins to die).
pub fn steady_part(event: &str) -> (f32, f32) {
match event {
"airdef:drone.shahed" => (0.2, 2.2),
"airdef:drone.geran3" => (0.2, 2.0),
"airdef:drone.lancet" => (0.2, 1.4),
"airdef:drone.cruise" => (0.2, 2.6),
"airdef:drone.rocket" => (0.2, 2.6),
"airdef:missile.flight" => (0.2, 2.2),
_ => (0.0, 0.0),
}
}
struct Live {
id: u32,
event: &'static str,
missed: u32,
dying: u32,
}
#[derive(Default)]
pub struct Engines {
live: BTreeMap<u32, Live>,
/// Engines that died and are still declared (a wreck falling): not started again.
dead: BTreeSet<u32>,
next_id: u32,
}
impl Engines {
pub fn is_empty(&self) -> bool {
self.live.is_empty()
}
/// The game released every source: forget them (nothing to stop).
pub fn reset(&mut self) {
self.live.clear();
self.dead.clear();
}
fn voice(d: &Decl, dying: u32) -> Voice {
let u = (dying as f32 / DYING as f32).min(1.0);
let (loop_from, loop_to) = steady_part(d.event);
Voice {
event: d.event,
at: d.at,
vel: d.vel,
// A shot-down engine coughs: the pitch falls by almost half, the sound fades.
gain: d.gain * (1.0 - u),
pitch: d.pitch * (1.0 - 0.45 * u),
attack: 0.3,
release: 0.25,
loop_from,
loop_to,
}
}
/// One tick: what is declared now, and whether the picture cut (a seek or a new shot).
pub fn step(&mut self, mut decls: Vec<Decl>, cut: bool, listener: DVec3) -> Vec<Op> {
let mut ops = Vec::new();
if cut {
for (_, l) in std::mem::take(&mut self.live) {
ops.push(Op::Stop(l.id));
}
self.dead.clear();
}
if decls.len() > MAX {
decls.sort_by(|a, b| {
a.at.distance_squared(listener)
.total_cmp(&b.at.distance_squared(listener))
.then(a.key.cmp(&b.key))
});
decls.truncate(MAX);
}
decls.sort_by_key(|d| d.key);
for l in self.live.values_mut() {
l.missed += 1;
}
let declared: BTreeSet<u32> = decls.iter().map(|d| d.key).collect();
self.dead.retain(|k| declared.contains(k));
for d in &decls {
if self.dead.contains(&d.key) {
continue;
}
match self.live.get_mut(&d.key) {
Some(l) if l.event == d.event => {
l.missed = 0;
if d.damaged {
l.dying += 1;
}
if l.dying >= DYING {
ops.push(Op::Stop(l.id));
self.live.remove(&d.key);
self.dead.insert(d.key);
} else {
ops.push(Op::Update(l.id, Self::voice(d, l.dying)));
}
}
other => {
// A new engine, or the same entity with another sound: the old one releases.
if let Some(l) = other {
ops.push(Op::Stop(l.id));
}
self.next_id = self.next_id.wrapping_add(1).max(1);
let dying = u32::from(d.damaged);
ops.push(Op::Start(self.next_id, Self::voice(d, dying)));
self.live.insert(
d.key,
Live {
id: self.next_id,
event: d.event,
missed: 0,
dying,
},
);
}
}
}
// Gone for good: the entity was removed (burnout, a hit, a crash).
let gone: Vec<u32> = self
.live
.iter()
.filter(|(_, l)| l.missed > GRACE)
.map(|(k, _)| *k)
.collect();
for k in gone {
if let Some(l) = self.live.remove(&k) {
ops.push(Op::Stop(l.id));
}
}
ops
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(key: u32, x: f64) -> Decl {
Decl {
key,
event: "airdef:drone.shahed",
at: DVec3::new(x, 50.0, 0.0),
vel: DVec3::new(30.0, 0.0, 0.0),
gain: 0.8,
pitch: 1.0,
damaged: false,
}
}
fn starts(ops: &[Op]) -> usize {
ops.iter().filter(|o| matches!(o, Op::Start(..))).count()
}
fn stops(ops: &[Op]) -> usize {
ops.iter().filter(|o| matches!(o, Op::Stop(_))).count()
}
#[test]
fn an_engine_ignites_follows_and_stops_when_its_entity_goes() {
let mut e = Engines::default();
let ops = e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
assert_eq!(starts(&ops), 1);
let Op::Start(id, v) = &ops[0] else { panic!() };
assert_eq!((v.attack, v.loop_from, v.loop_to), (0.3, 0.2, 2.2));
for i in 1..10 {
let ops = e.step(vec![d(1, f64::from(i))], false, DVec3::ZERO);
assert!(matches!(&ops[..], [Op::Update(u, v)] if u == id && v.at.x == f64::from(i)));
}
// Undeclared for a moment: still sounding; then gone.
for _ in 0..GRACE {
assert!(e.step(vec![], false, DVec3::ZERO).is_empty());
}
let ops = e.step(vec![], false, DVec3::ZERO);
assert_eq!(ops, vec![Op::Stop(*id)]);
assert!(e.is_empty());
}
#[test]
fn a_shot_down_engine_coughs_and_dies_and_stays_dead_while_it_falls() {
let mut e = Engines::default();
e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
let mut last = 1.0;
let mut stopped_at = None;
for t in 1..60 {
let mut f = d(1, 0.0);
f.damaged = true;
let ops = e.step(vec![f], false, DVec3::ZERO);
match ops.as_slice() {
[Op::Update(_, v)] => {
assert!(v.pitch < last, "the pitch falls");
last = v.pitch;
}
[Op::Stop(_)] => stopped_at = stopped_at.or(Some(t)),
[] => {}
o => panic!("{o:?}"),
}
}
assert_eq!(stopped_at, Some(DYING));
assert!(last < 0.6);
// The wreck is gone, a new drone with the same key sounds again.
e.step(vec![], false, DVec3::ZERO);
assert_eq!(starts(&e.step(vec![d(1, 0.0)], false, DVec3::ZERO)), 1);
}
#[test]
fn a_cut_stops_every_engine_and_starts_them_under_new_ids() {
let mut e = Engines::default();
let a = e.step(vec![d(1, 0.0), d(2, 5.0)], false, DVec3::ZERO);
let ops = e.step(vec![d(1, 100.0), d(2, 105.0)], true, DVec3::ZERO);
assert_eq!((stops(&ops), starts(&ops)), (2, 2));
let old: Vec<u32> = a.iter().filter_map(|o| if let Op::Start(i, _) = o { Some(*i) } else { None }).collect();
for o in &ops {
if let Op::Start(i, _) = o {
assert!(!old.contains(i), "a new id, so nothing glides across the jump");
}
}
}
#[test]
fn another_sound_for_the_same_entity_restarts_it() {
let mut e = Engines::default();
e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
let mut other = d(1, 0.0);
other.event = "airdef:drone.rocket";
let ops = e.step(vec![other], false, DVec3::ZERO);
assert_eq!((stops(&ops), starts(&ops)), (1, 1));
}
#[test]
fn only_the_nearest_are_kept_and_the_same_input_gives_the_same_ops() {
let run = || {
let mut e = Engines::default();
let all: Vec<Decl> = (0..300).map(|i| d(i, f64::from(i) * 3.0)).collect();
e.step(all, false, DVec3::ZERO)
};
let ops = run();
assert_eq!(starts(&ops), MAX);
assert_eq!(ops, run());
let far = ops.iter().filter_map(|o| if let Op::Start(_, v) = o { Some(v.at.x) } else { None }).fold(0.0, f64::max);
assert!(far <= (MAX as f64) * 3.0);
}
#[test]
fn a_hundred_seek_cycles_leave_no_engine_behind() {
let mut e = Engines::default();
let mut open = std::collections::BTreeSet::new();
for round in 0..100 {
let ops = e.step(vec![d(1, round as f64), d(2, 0.0)], round % 3 == 0, DVec3::ZERO);
for o in ops {
match o {
Op::Start(i, _) => {
open.insert(i);
}
Op::Stop(i) => {
assert!(open.remove(&i), "a stop for a source that was never started");
}
Op::Update(i, _) => assert!(open.contains(&i)),
}
}
}
for o in e.step(vec![], true, DVec3::ZERO) {
if let Op::Stop(i) = o {
open.remove(&i);
}
}
assert!(open.is_empty(), "every start was stopped");
}
}
+8 -3
View File
@@ -308,10 +308,15 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
Some(Shot::new(a, b, &[(a, 1.0), (b, 1.4)], variants, head, "", i))
};
match e.kind {
EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile } => {
EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile, outcome } => {
let sp = rec.sam(sam).map_or(pos - DVec3::new(150.0, 0.0, 0.0), |s| s.1);
let name = format!("{} №{}", sam_kind.name(), sam_index + 1);
let sub = format!("Перехват «{}»", kind.spec().name);
let how = match outcome {
crate::sim::outcome::Outcome::DisabledFalling => "",
crate::sim::outcome::Outcome::Breakup => ": разрушение в воздухе",
crate::sim::outcome::Outcome::AirDetonation => ": детонация в воздухе",
};
let sub = format!("Перехват «{}»{how}", kind.spec().name);
let mt = missile.and_then(|m| rec.track(m).map(|t| (m, t.first as f64)));
match mt {
Some((m, tl)) => {
@@ -638,7 +643,7 @@ mod tests {
rec.events.push(Event {
at: kill,
pos,
kind: EventKind::Kill { drone: 1, kind: DroneKind::Geran3, sam: 5, sam_kind: SamKind::S400, sam_index: 0, missile: missile.then_some(2) },
kind: EventKind::Kill { drone: 1, kind: DroneKind::Geran3, sam: 5, sam_kind: SamKind::S400, sam_index: 0, missile: missile.then_some(2), outcome: Default::default() },
});
rec
}
+95 -3
View File
@@ -13,6 +13,8 @@ use horizon_mod_sdk::{
};
mod cinema;
mod engines;
mod commands;
mod layout;
mod film;
mod monitor;
@@ -106,6 +108,11 @@ struct AirDef {
punch: f32,
/// Sounds on their way to the player at the speed of sound.
pending: Vec<PendingSound>,
/// The engines as persistent sound sources (#95), and the source generation they were started under.
engines: engines::Engines,
engine_gen: u32,
/// Whether the engines are a replay's (a change of mode cuts them).
engines_replay: bool,
/// The handles `render.model-id` gave the model names (valid under `model_generation`); none:
/// a name the game doesn't know.
model_ids: std::collections::HashMap<&'static str, Option<u32>>,
@@ -148,6 +155,30 @@ fn switch_allowed(exporting: bool) -> Result<(), String> {
}
}
/// An engine voice as a `render.sound-sources` entry.
fn source_op(kind: render::SourceOpKind, id: u32, v: &engines::Voice) -> render::SourceOp {
render::SourceOp {
kind,
id,
event: v.event.to_string(),
x: v.at.x,
y: v.at.y,
z: v.at.z,
vx: v.vel.x as f32,
vy: v.vel.y as f32,
vz: v.vel.z as f32,
gain: v.gain,
pitch: v.pitch,
looping: true,
priority: 1.0,
attack: v.attack,
release: v.release,
doppler: engines::DOPPLER,
loop_from: v.loop_from,
loop_to: v.loop_to,
}
}
/// A sound heard later: when, what, where, and how loud and high it was.
struct PendingSound {
due: u64,
@@ -614,7 +645,59 @@ impl AirDef {
}
}
fn emit(&mut self, out: Out) {
/// The engines the simulation or the replay declared become persistent sound sources (#95):
/// started, followed, stopped. The one-shots below stay as they were.
fn step_engines(&mut self, out: &mut Out) {
let replay = self.replay.as_ref().is_some_and(|r| r.filming());
let cut = std::mem::take(&mut out.engine_cut) || replay != self.engines_replay;
self.engines_replay = replay;
let generation = render::sound_source_generation();
if generation != self.engine_gen {
// The game released every source (a world closed, a film began or ended).
self.engines.reset();
self.engine_gen = generation;
}
if self.engines.is_empty() && out.engines.is_empty() && !cut {
return;
}
let ops: Vec<render::SourceOp> = self
.engines
.step(std::mem::take(&mut out.engines), cut, self.listener)
.into_iter()
.map(|op| match op {
engines::Op::Start(id, v) => source_op(render::SourceOpKind::Start, id, &v),
engines::Op::Update(id, v) => source_op(render::SourceOpKind::Update, id, &v),
engines::Op::Stop(id) => render::SourceOp {
kind: render::SourceOpKind::Stop,
id,
event: String::new(),
x: 0.0,
y: 0.0,
z: 0.0,
vx: 0.0,
vy: 0.0,
vz: 0.0,
gain: 0.0,
pitch: 1.0,
looping: false,
priority: 1.0,
attack: 0.0,
release: 0.0,
doppler: 0.0,
loop_from: 0.0,
loop_to: 0.0,
},
})
.collect();
if !ops.is_empty() && !render::sound_sources(generation, &ops) {
// Released between the two calls: start them again under the new generation.
self.engines.reset();
self.engine_gen = u32::MAX;
}
}
fn emit(&mut self, mut out: Out) {
self.step_engines(&mut out);
if self.sim.shader {
render::set_params(&self.shader_params(&out));
}
@@ -1368,7 +1451,10 @@ impl AirDef {
}
}
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|film [best|all|N]|export [имя] [best|all|N] [fps=60 size=1920x1080 crf=14 sec=N noaudio]|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 | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
/// The help line: generated from the command tree (`commands.rs`), which the game checks lines with.
fn help() -> String {
commands::help()
}
impl Mod for AirDef {
fn init() -> Result<(Self, Registration), String> {
@@ -1400,6 +1486,9 @@ impl Mod for AirDef {
listener: DVec3::ZERO,
punch: 0.0,
pending: Vec::new(),
engines: engines::Engines::default(),
engine_gen: u32::MAX,
engines_replay: false,
model_ids: std::collections::HashMap::new(),
model_generation: u32::MAX,
by_id: Vec::new(),
@@ -1422,6 +1511,8 @@ impl Mod for AirDef {
me.block_log = Some(render::world_log_start());
me.sim.shader = true;
me.apply_far();
// Subcommands, typed arguments and the saved scenarios' names for the chat box (#98).
commands::register(&scenario::saved_names());
Ok((me, Registration::new().command("airdef").on_tick()))
}
@@ -1541,6 +1632,7 @@ impl Mod for AirDef {
out.models.clear();
out.particles.clear();
out.sounds.clear();
out.engines.clear();
out.shake = None;
}
r.tick(&self.sim, &mut self.ground, self.listener, &mut out);
@@ -1946,7 +2038,7 @@ impl Mod for AirDef {
self.sim.crashes,
self.ground.world.text()
)),
_ => return Err(HELP.to_string()),
_ => return Err(help()),
};
self.emit(out);
Ok(reply)
+202 -8
View File
@@ -68,6 +68,8 @@ pub struct Player {
fov: f32,
/// The last tick whose events made their sounds.
heard: f64,
/// The replay time the engines were last declared at (a jump back or far ahead cuts them).
engine_t: f64,
/// Time stands still (Space, or the end reached).
paused: bool,
/// The film's own clock in seconds (the replay's moment follows from the plan).
@@ -125,13 +127,23 @@ fn clock(ticks: f64) -> String {
format!("{:02}:{:04.1}", (s / 60.0) as u32, s % 60.0)
}
/// How a kill ended, when it was not the plain fall.
fn outcome_note(outcome: crate::sim::outcome::Outcome) -> &'static str {
use crate::sim::outcome::Outcome;
match outcome {
Outcome::DisabledFalling => "",
Outcome::Breakup => " (разрушение)",
Outcome::AirDetonation => " (детонация в воздухе)",
}
}
/// What an event was, in a line.
pub fn title(rec: &Recording, i: usize) -> String {
let e = &rec.events[i];
match e.kind {
EventKind::Kill { kind, sam_kind, sam_index, missile, .. } => {
EventKind::Kill { kind, sam_kind, sam_index, missile, outcome, .. } => {
let how = if missile.is_some() { "ракетой" } else { "огнём пушек" };
format!("{} №{} сбивает «{}» {how}", sam_kind.name(), sam_index + 1, kind.spec().name)
format!("{} №{} сбивает «{}» {how}{}", sam_kind.name(), sam_index + 1, kind.spec().name, outcome_note(outcome))
}
EventKind::Hit { kind, object: Some(o), .. } => format!("«{}» поражает объект №{}", kind.spec().name, o + 1),
EventKind::Hit { kind, sam: Some((sk, si)), .. } => format!("«{}» бьёт по {} №{}", kind.spec().name, sk.name(), si + 1),
@@ -146,8 +158,8 @@ pub fn title(rec: &Recording, i: usize) -> String {
pub fn row(rec: &Recording, i: usize) -> String {
let e = &rec.events[i];
let what = match e.kind {
EventKind::Kill { kind, sam_kind, sam_index, missile, .. } => {
format!("{}-{} > {}{}", sam_kind.name(), sam_index + 1, kind.spec().name, if missile.is_none() { " (пушки)" } else { "" })
EventKind::Kill { kind, sam_kind, sam_index, missile, outcome, .. } => {
format!("{}-{} > {}{}{}", sam_kind.name(), sam_index + 1, kind.spec().name, if missile.is_none() { " (пушки)" } else { "" }, outcome_note(outcome))
}
EventKind::Hit { kind, object: Some(o), .. } => format!("{} > объект {}", kind.spec().name, o + 1),
EventKind::Hit { kind, sam: Some((sk, si)), .. } => format!("{} > {}-{}", kind.spec().name, sk.name(), si + 1),
@@ -163,8 +175,9 @@ pub fn row(rec: &Recording, i: usize) -> String {
pub fn interest(rec: &Recording, i: usize) -> f64 {
let e = &rec.events[i];
match e.kind {
EventKind::Kill { kind, missile, .. } => {
EventKind::Kill { kind, missile, outcome, .. } => {
let spec = kind.spec();
let bonus = if outcome == crate::sim::outcome::Outcome::AirDetonation { 2.0 } else { 0.0 };
let class = match spec.class {
Class::Ballistic => 10.0,
Class::Supersonic => 7.0,
@@ -179,7 +192,7 @@ pub fn interest(rec: &Recording, i: usize) -> f64 {
};
// A kill close to what it was going for.
let close = rec.objects.iter().chain(rec.sams.iter().map(|s| &s.2)).any(|o| o.distance(e.pos) < 40.0);
class + rare + if spec.decoy { -3.0 } else { 0.0 } + if missile.is_none() { 1.0 } else { 0.0 } + if close { 3.0 } else { 0.0 }
bonus + class + rare + if spec.decoy { -3.0 } else { 0.0 } + if missile.is_none() { 1.0 } else { 0.0 } + if close { 3.0 } else { 0.0 }
}
EventKind::Hit { power, object, sam, .. } => {
power as f64 * 1.5 + if object.is_some() { 6.0 } else if sam.is_some() { 5.0 } else { 0.0 }
@@ -254,6 +267,7 @@ impl Player {
look: DVec3::NEG_Z,
fov: 50.0,
heard: start,
engine_t: -1.0,
paused: false,
tau: 0.0,
wait: false,
@@ -432,6 +446,48 @@ impl Player {
sim.draw_ghosts(rec, self.t, GHOSTS, out);
self.direct(rec, ground);
self.sounds(rec, listener, out);
self.engines(rec, listener, false, out);
}
/// The engines as the recording had them at the replay's time, for persistent sound sources
/// (#95): every flying drone and lit interceptor, where its track puts it now, moving as the
/// track moves, placed round the listener like `sounds`. Taken from the tracks and the replay's
/// time, never from the live entities. Silent while time stands still, runs too fast to
/// follow, or a title card shows; a seek, a clip change or a new shot cuts them (they start
/// afresh), so no sound glides across a jump.
fn engines(&mut self, rec: &Recording, listener: DVec3, shot_changed: bool, out: &mut Out) {
let moved = self.t - self.engine_t;
if self.engine_t < 0.0 || shot_changed || moved < -1e-9 || moved > self.rate * 1.5 + 2.0 {
out.engine_cut = true;
}
self.engine_t = self.t;
if self.rate <= 0.0 || self.rate > 4.0 || self.card > 0 {
return;
}
let step = STEP as f64;
for (k, tr) in rec.tracks.iter().enumerate() {
let Some(p) = tr.at(rec.origin, self.t) else { continue };
let ahead = tr.at(rec.origin, self.t + step).unwrap_or(p);
let behind = tr.at(rec.origin, self.t - step).unwrap_or(p);
// Blocks per tick of the replay: the track's, times how fast the replay runs.
let vel = (ahead - behind) / (2.0 * step) * self.rate;
let key = 0x8000_0000 | k as u32;
let at = listener + (p - self.eye);
match tr.what {
What::Drone(kind) => {
if let Some((event, pitch)) = kind.engine_voice() {
let falling = tr.fell.is_some_and(|f| self.t >= f as f64);
out.engine(key, event, at, vel, 0.8, pitch, falling);
}
}
What::Missile(kind, _) => {
if self.t - tr.first as f64 > kind.lit_after() as f64 {
let (event, gain, pitch) = kind.engine_voice();
out.engine(key, event, at, vel, gain, pitch, false);
}
}
}
}
}
/// Sounds as the camera hears them: moved so the player stands where the camera is.
@@ -439,13 +495,16 @@ impl Player {
let heard = |p: DVec3| listener + (p - self.eye);
for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) {
let event = match e.kind {
EventKind::Kill { .. } => "airdef:blast.air",
EventKind::Kill { kind, outcome, .. } => crate::sim::outcome::look(kind, outcome).3,
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
EventKind::SamLost { .. } => "airdef:blast.big",
EventKind::Split { .. } => "airdef:boom.sonic",
};
let gain = if let EventKind::Kill { kind, .. } = e.kind { crate::sim::record::kill_look(kind).2 } else { 1.0 };
let gain = if let EventKind::Kill { kind, outcome, .. } = e.kind { crate::sim::outcome::look(kind, outcome).2 } else { 1.0 };
out.sound(event, heard(e.pos), gain, 1.0);
if let EventKind::Kill { outcome: crate::sim::outcome::Outcome::AirDetonation, .. } = e.kind {
out.sound("airdef:boom.sonic", heard(e.pos), 0.8, 1.2);
}
}
for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) {
if let What::Missile(kind, _) = tr.what
@@ -498,6 +557,7 @@ impl Player {
sim.draw_ghosts(rec, self.t, base, out);
if !self.wait {
self.sounds(rec, listener, out);
self.engines(rec, listener, changed, out);
} else {
self.heard = self.t;
}
@@ -990,3 +1050,137 @@ mod surface_tests {
assert_eq!(above_surface(eye, Some(None)), eye);
}
}
#[cfg(test)]
mod engine_tests {
use super::*;
use crate::engines::{Engines, Op};
use crate::sim::{MissileKind, record::Recording};
fn recording() -> Recording {
let mut rec = Recording::blank(2000);
// A Shahed flying 1000 blocks east over 1000 ticks, shot down at 600; an interceptor from 400.
rec.straight(1, What::Drone(DroneKind::Shahed), 0, 1000, DVec3::new(0.0, 80.0, 0.0), DVec3::new(1000.0, 80.0, 0.0));
rec.tracks[0].fell = Some(600);
rec.straight(2, What::Missile(MissileKind::Pac3, 0), 400, 700, DVec3::new(0.0, 0.0, 0.0), DVec3::new(600.0, 80.0, 0.0));
rec
}
fn player(rec: &Recording) -> Player {
let mut p = Player::new(1, Mode::Full, &Sim::new(1));
p.eye = DVec3::new(500.0, 80.0, 30.0);
let _ = rec;
p
}
/// A host that remembers which sources run.
#[derive(Default)]
struct Host(std::collections::BTreeMap<u32, [f64; 3]>);
impl Host {
fn apply(&mut self, ops: Vec<Op>) {
for o in ops {
match o {
Op::Start(id, v) => assert!(self.0.insert(id, [v.at.x, v.at.y, v.at.z]).is_none()),
Op::Update(id, v) => assert!(self.0.insert(id, [v.at.x, v.at.y, v.at.z]).is_some()),
Op::Stop(id) => assert!(self.0.remove(&id).is_some()),
}
}
}
}
#[test]
fn engines_follow_the_recorded_tracks_at_the_replays_time_around_the_listener() {
let rec = recording();
let mut p = player(&rec);
let listener = DVec3::new(-5.0, 70.0, 12.0);
p.t = 450.0;
p.rate = 1.0;
let mut out = Out::default();
p.engines(&rec, listener, false, &mut out);
// The first declaration of a replay starts the engines afresh.
assert!(out.engine_cut);
assert_eq!(out.engines.len(), 2);
let drone = out.engines.iter().find(|d| d.key == 0x8000_0000).unwrap();
// The drone at tick 450 is at x = 450; the eye stands at x = 500; the player at -5.
assert!((drone.at.x - (listener.x + (450.0 - 500.0))).abs() < 1e-6, "{:?}", drone.at);
assert!((drone.at.z - (listener.z + (0.0 - 30.0))).abs() < 1e-6);
assert!(!drone.damaged && drone.vel.x > 0.0);
// The interceptor is lit after its cold launch only.
p.t = 401.0;
let mut out = Out::default();
p.engines(&rec, listener, false, &mut out);
assert_eq!(out.engines.iter().filter(|d| d.key == 0x8000_0001).count(), usize::from(401.0 - 400.0 > f64::from(MissileKind::Pac3.lit_after())));
}
#[test]
fn pause_seek_and_cut_release_and_restart_engines_without_leaving_any() {
let rec = recording();
let mut p = player(&rec);
let mut engines = Engines::default();
let mut host = Host::default();
let listener = DVec3::ZERO;
let mut tick = |p: &mut Player, engines: &mut Engines, host: &mut Host, shot: bool| {
let mut out = Out::default();
p.engines(&rec, listener, shot, &mut out);
host.apply(engines.step(std::mem::take(&mut out.engines), out.engine_cut, listener));
};
p.t = 100.0;
p.rate = 1.0;
for _ in 0..30 {
p.t += 1.0;
tick(&mut p, &mut engines, &mut host, false);
}
assert_eq!(host.0.len(), 1, "the drone alone");
// Paused: the sources go after their grace.
p.rate = 0.0;
for _ in 0..6 {
tick(&mut p, &mut engines, &mut host, false);
}
assert!(host.0.is_empty());
// Played on again: they start again without a cut.
p.rate = 1.0;
tick(&mut p, &mut engines, &mut host, false);
assert_eq!(host.0.len(), 1);
let before: Vec<u32> = host.0.keys().copied().collect();
// A seek back and forward, and a hundred shot changes: always one drone, a new source each time.
p.t = 50.0;
tick(&mut p, &mut engines, &mut host, false);
assert_eq!(host.0.len(), 1);
assert!(!host.0.contains_key(&before[0]), "the seek restarted the source");
p.t = 380.0;
tick(&mut p, &mut engines, &mut host, false);
for i in 0..100 {
p.t = 380.0 + f64::from(i % 7);
tick(&mut p, &mut engines, &mut host, i % 3 == 0);
assert!(host.0.len() <= 2);
}
// The drone is shot down at 600: its engine dies away and stays dead while the wreck falls
// (the interceptor, flying till 700, is the one source left).
p.t = 599.0;
tick(&mut p, &mut engines, &mut host, false);
let mut silent_after = None;
for i in 0..40 {
p.t = 600.0 + f64::from(i);
tick(&mut p, &mut engines, &mut host, false);
if host.0.len() == 1 && silent_after.is_none() {
silent_after = Some(i);
}
}
assert!(silent_after.is_some_and(|i| i <= 25), "{silent_after:?}");
// The film ends: nothing is left.
p.rate = 0.0;
for _ in 0..6 {
tick(&mut p, &mut engines, &mut host, false);
}
assert!(host.0.is_empty());
}
#[test]
fn a_fast_replay_is_silent() {
let rec = recording();
let mut p = player(&rec);
p.t = 100.0;
p.rate = 16.0;
let mut out = Out::default();
p.engines(&rec, DVec3::ZERO, false, &mut out);
assert!(out.engines.is_empty());
}
}
+17 -5
View File
@@ -20,6 +20,16 @@ fn valid(name: &str) -> Result<String, String> {
Ok(n)
}
/// The saved scenarios' names.
pub(crate) fn saved_names() -> Vec<String> {
names()
}
/// Tells the chat box the names now saved, so Tab offers them.
fn publish(all: &[String]) {
command::set_suggestions(commands::SCENARIOS, all);
}
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()
}
@@ -82,11 +92,12 @@ impl AirDef {
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());
let before = names();
let all = commands::names_after_save(before.clone(), &name);
if all != before {
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
}
publish(&all);
Ok(format!(
"Сценарий «{name}» сохранён: комплексов {}, объектов {}{}",
self.sim.sams.len(),
@@ -109,13 +120,14 @@ impl AirDef {
}
["delete", name] => {
let name = valid(name)?;
let mut all = names();
let all = names();
if !all.contains(&name) {
return Err(format!("Нет сценария «{name}»"));
}
all.retain(|n| *n != name);
let all = commands::names_after_delete(all, &name);
storage::delete(&key(&name));
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
publish(&all);
Ok(format!("Сценарий «{name}» удалён"))
}
_ => Err("/airdef scenario save <имя> [fresh] | load <имя> [force] | list | delete <имя>".into()),
+121 -41
View File
@@ -12,6 +12,7 @@ use std::collections::HashMap;
mod damage;
pub mod fires;
mod raid;
pub mod sites;
pub mod record;
mod sight;
mod terrain;
@@ -19,6 +20,8 @@ mod ew;
pub mod lights;
mod logistics;
mod orders;
mod voices;
pub mod outcome;
mod units;
mod why;
pub mod world;
@@ -30,6 +33,7 @@ pub use ew::EW_RADIUS;
pub use damage::{Comps, Part, PartState};
pub use sight::{Ident, Track};
use damage::dice;
use outcome::{Impact, Outcome};
pub use units::{Family, SamKind};
use units::Muzzle;
pub use raid::{Approach, Aim, RaidOrder};
@@ -78,6 +82,10 @@ pub struct Out {
pub models: Vec<Model>,
pub particles: Vec<Particle>,
pub sounds: Vec<(&'static str, DVec3, f32, f32)>,
/// The engines sounding this tick (persistent sources, `crate::engines`), and whether the
/// picture cut (a replay seek or a new shot): every engine then starts afresh.
pub engines: Vec<crate::engines::Decl>,
pub engine_cut: bool,
pub commands: Vec<String>,
/// Camera jolt (strength, seconds), the strongest this tick.
pub shake: Option<(f32, f32)>,
@@ -106,6 +114,10 @@ impl Out {
pub fn sound(&mut self, event: &'static str, at: DVec3, volume: f32, pitch: f32) {
self.sounds.push((event, at, volume, pitch));
}
/// An engine that sounds: `vel` is the simulation's, blocks a tick.
pub fn engine(&mut self, key: u32, event: &'static str, at: DVec3, vel: DVec3, gain: f32, pitch: f32, damaged: bool) {
self.engines.push(crate::engines::Decl { key, event, at, vel: vel * 20.0, gain, pitch, damaged });
}
fn jolt(&mut self, strength: f32, seconds: f32) {
if self.shake.is_none_or(|(s, _)| s < strength) {
self.shake = Some((strength.min(1.0), seconds));
@@ -1374,6 +1386,8 @@ enum Fx {
Fireball { pos: DVec3, size: f64, age: u32, life: u32, spin: DQuat },
/// A falling piece of a drone.
Debris { pos: DVec3, vel: DVec3, rot: DQuat, spin: DQuat, age: u32 },
/// A piece thrown off by a breakup or an air detonation: its own drag and life.
Piece { pos: DVec3, vel: DVec3, rot: DQuat, piece: outcome::Piece, age: u32 },
/// A glowing red tracer round.
Tracer { pos: DVec3, vel: DVec3, life: u32 },
}
@@ -1424,6 +1438,8 @@ pub struct Sim {
later: Vec<(u64, DVec3, u32, bool)>,
/// A planned raid's drones waiting for their launch tick.
pub queue: Vec<(u64, Drone)>,
/// The raid's launch sites and their jobs (#100).
pub sites: sites::Sites,
/// A raid being planned.
plan: Option<raid::Plan>,
/// Each marked object's health, 0 to 100 (`targets`' order).
@@ -1492,6 +1508,7 @@ impl Sim {
horizons: HashMap::new(),
later: Vec::new(),
queue: Vec::new(),
sites: sites::Sites::default(),
plan: None,
target_health: Vec::new(),
wave: None,
@@ -1597,6 +1614,7 @@ impl Sim {
self.fx.clear();
self.blasts.clear();
self.queue.clear();
self.sites.clear();
self.plan = None;
self.wave = None;
// Ammunition still to cook off, flares in the sky.
@@ -1611,7 +1629,7 @@ impl Sim {
self.tick += 1;
self.plan_step(Some(&mut *ground), out);
self.adapt();
self.release_queue();
self.release_queue(out);
self.step_drones(ground, player, out);
self.step_later(ground, player, out);
self.step_moves(ground, out);
@@ -1623,6 +1641,7 @@ impl Sim {
self.step_missiles(ground, out);
self.step_fx(ground, out);
self.draw_targets(out);
self.draw_sites(out);
self.step_wrecks(out);
self.step_fires(ground, out);
self.report_wave(out);
@@ -1674,6 +1693,9 @@ impl Sim {
out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1);
}
out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2);
if let Some((event, pitch)) = d.kind.engine_voice() {
out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, true);
}
if sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.3, &sweep, now) || d.age > 600 {
// The wreck's warhead may still go off where it lands.
let warhead = if spec.decoy || !self.rng.chance(0.35) { 0 } else { spec.warhead.min(2) };
@@ -1917,9 +1939,9 @@ impl Sim {
out.particles("minecraft:cloud", back - d.vel.normalize_or_zero() * 1.5, 0.15, DVec3::ZERO, 0.005, 1);
}
}
let (event, pitch, every) = spec.sound;
if d.age % every == 0 {
out.sound(event, d.pos, 1.0, pitch);
// The engine is one source that follows the drone (#95).
if let Some((event, pitch)) = d.kind.engine_voice() {
out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, false);
}
}
for (drone, pos) in splits {
@@ -2088,11 +2110,27 @@ impl Sim {
self.fx.push((id, Fx::Fireball { pos, size, age: 0, life, spin }));
}
/// A drone hit by air defence: a flash, pieces, and it falls burning.
fn shoot_down(&mut self, idx: usize, by: u32, missile: Option<u32>, out: &mut Out) {
/// A drone hit by the guns of system `by`.
fn shoot_down(&mut self, idx: usize, by: u32, missile: Option<u32>, out: &mut Out) -> Option<Outcome> {
let kind = self.sams.iter().find(|s| s.id == by).map_or(SamKind::Gepard, |s| s.kind);
let shell = kind.spec().gun.as_ref().map_or(5.0, |g| g.shell);
let at = self.drones[idx].pos;
self.shoot_down_by(idx, by, missile, Impact::gun(kind, at, shell), out)
}
/// A drone brought down by `imp` from system `by`: how it ends is rolled once (combat randomness, so a
/// detonation really means no ground impact), recorded with the kill, and drawn from the visual randomness
/// only. The kill is counted here and nowhere else.
fn shoot_down_by(&mut self, idx: usize, by: u32, missile: Option<u32>, imp: Impact, out: &mut Out) -> Option<Outcome> {
if !self.drones[idx].alive() {
return;
return None;
}
let dkind = self.drones[idx].kind;
let roll = self.rng.f();
let outcome = outcome::choose(dkind, &imp, roll);
let spec = dkind.spec();
// A broken-up target's warhead section may stay whole: it falls on as a wreck (and may go off on landing).
let keeps_warhead = outcome == Outcome::Breakup && !spec.decoy && spec.warhead > 0 && self.rng.chance(outcome::WARHEAD_SURVIVES);
let sector = self.drones[idx].sector as usize;
self.learn(sector, true);
let until = self.tick + 70;
@@ -2100,33 +2138,72 @@ impl Sim {
s.kill_view = Some((self.drones[idx].id, self.drones[idx].pos, until));
}
if let Some((i, s)) = self.sams.iter().enumerate().find(|(_, s)| s.id == by) {
let (drone, kind, pos) = (self.drones[idx].id, self.drones[idx].kind, self.drones[idx].pos);
let kill = record::EventKind::Kill { drone, kind, sam: by, sam_kind: s.kind, sam_index: i, missile };
let (drone, kind, pos) = (self.drones[idx].id, dkind, self.drones[idx].pos);
let kill = record::EventKind::Kill { drone, kind, sam: by, sam_kind: s.kind, sam_index: i, missile, outcome };
self.log(pos, kill);
}
let d = &mut self.drones[idx];
let before = d.vel;
d.flight = Flight::Falling;
d.age = 0;
d.vel = d.vel * 0.6 + DVec3::new(0.0, 0.15, 0.0);
d.vel = match outcome {
Outcome::DisabledFalling => d.vel * 0.6 + DVec3::new(0.0, 0.15, 0.0),
_ => d.vel * 0.35 + DVec3::new(0.0, 0.1, 0.0),
};
let pos = d.pos;
let vel = d.vel;
self.kills += 1;
if let Some(s) = self.sams.iter_mut().find(|s| s.id == by) {
s.kills += 1;
}
// The look of the kill goes by the warhead it blew up (replays use the same).
let (size, life, gain) = record::kill_look(self.drones[idx].kind);
// The look of the kill goes by the warhead it blew up and by how it ended (replays use the same).
let (size, life, gain, sound) = outcome::look(dkind, outcome);
self.fireball(pos, size as f64, life / 9);
out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4);
out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25);
out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30);
out.sound("airdef:blast.air", pos, gain, 1.0 + self.vis.f() as f32 * 0.2);
for _ in 0..5 {
let id = fx_ident(&mut self.fx_next);
let v = vel + DVec3::new(self.vis.s(), self.vis.f() * 0.8, self.vis.s()) * 0.35;
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.4, self.vis.s() * 0.4, self.vis.s() * 0.4);
self.fx.push((id, Fx::Debris { pos, vel: v, rot: DQuat::IDENTITY, spin, age: 0 }));
let pitch = 1.0 + self.vis.f() as f32 * 0.2;
match outcome {
Outcome::DisabledFalling => {
out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4);
out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25);
out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30);
for _ in 0..5 {
let id = fx_ident(&mut self.fx_next);
let v = vel + DVec3::new(self.vis.s(), self.vis.f() * 0.8, self.vis.s()) * 0.35;
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.4, self.vis.s() * 0.4, self.vis.s() * 0.4);
self.fx.push((id, Fx::Debris { pos, vel: v, rot: DQuat::IDENTITY, spin, age: 0 }));
}
}
Outcome::Breakup => {
out.particles("minecraft:explosion", pos, 0.8, DVec3::ZERO, 0.0, 3);
out.particles("minecraft:large_smoke", pos, 1.4, DVec3::ZERO, 0.08, 30);
out.particles("minecraft:firework", pos, 0.4, DVec3::ZERO, 0.3, 14);
}
Outcome::AirDetonation => {
out.particles("minecraft:flash", pos, 0.0, DVec3::ZERO, 0.0, 1);
out.particles("minecraft:explosion_emitter", pos, 0.8, DVec3::ZERO, 0.0, 2);
out.particles("minecraft:large_smoke", pos, size * 0.3, DVec3::new(0.0, 0.05, 0.0), 0.07, if self.shader { 20 } else { 40 });
out.particles("minecraft:firework", pos, 0.6, DVec3::ZERO, 0.5, 50);
// The shock arrives after the flash (`boom` sounds travel); the warhead's blast hurts what is near.
out.sound("airdef:boom.sonic", pos, 0.8, 1.2);
self.blast_damage(pos, 0, out);
}
}
out.sound(sound, pos, gain, pitch);
let seed = self.vis.next();
let mut alive = self.fx.iter().filter(|(_, f)| matches!(f, Fx::Piece { .. })).count();
for p in outcome::pieces(seed, outcome, before) {
if alive + 1 > outcome::PIECES_MAX {
break;
}
let id = fx_ident(&mut self.fx_next);
let rot = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s(), self.vis.s(), self.vis.s());
self.fx.push((id, Fx::Piece { pos, vel: p.vel, rot, piece: p, age: 0 }));
alive += 1;
}
if outcome == Outcome::AirDetonation || (outcome == Outcome::Breakup && !keeps_warhead) {
// The main mass is gone: nothing falls to the ground and nothing goes off there.
self.drones.remove(idx);
}
Some(outcome)
}
// ------------------------------------------------ air defence
@@ -2480,7 +2557,7 @@ impl Sim {
}
for (t, by) in gun_hits {
if let Some(i) = self.drones.iter().position(|d| d.id == t) {
self.shoot_down(i, by, None, out);
let _ = self.shoot_down(i, by, None, out);
}
}
for i in 0..self.sams.len() {
@@ -2627,24 +2704,12 @@ impl Sim {
done.push((m.id, None, false, m.sam));
continue;
}
if lit && spec.flame == 0.0 {
if m.age % 20 == 1 {
out.sound("airdef:drone.lancet", m.pos, 0.8, 1.6);
}
} else if lit {
if (m.age - spec.cold) % 46 == 1 {
let pitch = match m.kind {
MissileKind::Pac3 => 1.0,
MissileKind::M57e6 => 1.12,
MissileKind::M48n6 => 0.88,
MissileKind::M77n6 => 0.8,
MissileKind::IrisT | MissileKind::Amraam => 1.05,
MissileKind::M9m317 => 0.9,
MissileKind::M9m338 => 1.15,
MissileKind::P1sun => 1.7,
};
out.sound("airdef:missile.flight", m.pos, 1.0, pitch);
}
if lit {
// The motor is one source that follows the missile from ignition on (#95).
let (event, gain, pitch) = m.kind.engine_voice();
out.engine(0x4000_0000 | m.id, event, m.pos, m.vel, gain, pitch, false);
}
if lit && spec.flame > 0.0 {
let back = m.pos - new * 1.4 * spec.scale;
out.particles("minecraft:flame", back, 0.05, -new * 0.1, 0.01, 2);
if m.age % 2 == 0 {
@@ -2673,7 +2738,8 @@ impl Sim {
if let Some(t) = target.and_then(|t| self.drones.iter().position(|d| d.id == t)) {
self.drones[t].engaged = self.drones[t].engaged.saturating_sub(1);
if kill {
self.shoot_down(t, sam, Some(mid), out);
let (at, closing) = (self.drones[t].pos, (m.vel - self.drones[t].vel).length());
let _ = self.shoot_down_by(t, sam, Some(mid), Impact::missile(m.kind, at, closing), out);
}
}
// A missile that lost its target frees it for others.
@@ -2724,6 +2790,20 @@ impl Sim {
let landed = ground.at(*pos, now).is_some_and(|g| pos.y <= g);
!landed && *age < 200
}
Fx::Piece { pos, vel, rot, piece, age } => {
*age += 1;
outcome::fly(pos, vel, piece.drag);
*rot = piece.spin * *rot;
out.model(id, "airdef:debris", *pos, *rot, piece.scale, false);
if piece.burns {
out.particles("minecraft:large_smoke", *pos, 0.1, DVec3::new(0.0, 0.03, 0.0), 0.01, 1);
if *age % 3 == 0 {
out.particles("minecraft:flame", *pos, 0.1, DVec3::ZERO, 0.01, 1);
}
}
let landed = ground.at(*pos, now).is_some_and(|g| pos.y <= g);
!landed && *age < piece.life
}
Fx::Tracer { pos, vel, life } => {
*pos += *vel;
vel.y -= 0.02;
+356
View File
@@ -0,0 +1,356 @@
//! What a kill does to the air target (#97): it falls burning on, it breaks up, or its warhead goes
//! off in the air. The chances are game profiles, all here and nowhere else; they are not the real
//! weapons' figures. The roll is combat randomness (a detonation means no ground impact), one draw
//! per kill; everything drawn from it (pieces, sparks) is decoration and uses only the visual RNG.
//! The outcome is recorded with the kill, so a replay shows it and never rolls again.
use super::*;
/// How an air target ended up when it was brought down.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Outcome {
/// Control lost: the burning hull falls on with what momentum it had. A recording without an
/// outcome (an old one) is read as this, which is what every kill used to be.
#[default]
DisabledFalling,
/// The airframe tears into several pieces; the warhead section may survive and fall on.
Breakup,
/// The warhead goes off in the air: a flash and a rain of debris, nothing left to hit the ground.
AirDetonation,
}
/// What brought the target down.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum KillCause {
/// Shells of this system's guns.
Gun(SamKind),
Missile(MissileKind),
}
/// A hit, as far as the outcome is concerned.
#[derive(Clone, Copy, Debug)]
pub struct Impact {
pub cause: KillCause,
/// Game energy of the hit (see `energy_of`).
pub energy: f64,
pub at: DVec3,
/// Closing speed, blocks a tick.
pub rel_speed: f64,
}
impl Impact {
pub fn gun(sam: SamKind, at: DVec3, rel_speed: f64) -> Self {
Impact { cause: KillCause::Gun(sam), energy: energy_of(KillCause::Gun(sam)), at, rel_speed }
}
pub fn missile(kind: MissileKind, at: DVec3, rel_speed: f64) -> Self {
Impact { cause: KillCause::Missile(kind), energy: energy_of(KillCause::Missile(kind)), at, rel_speed }
}
}
// ---- the profiles: the one place to tune ------------------------------------------------------
/// Game energy of one hit: a shell is small, an interceptor's warhead is big.
pub fn energy_of(cause: KillCause) -> f64 {
match cause {
KillCause::Gun(SamKind::Gepard) => 0.15,
KillCause::Gun(_) => 0.13,
KillCause::Missile(m) => match m {
MissileKind::Pac3 => 1.0,
MissileKind::M57e6 => 0.5,
MissileKind::M48n6 => 1.2,
MissileKind::M77n6 => 1.6,
MissileKind::IrisT => 0.7,
MissileKind::Amraam => 0.6,
MissileKind::M9m317 => 0.8,
MissileKind::M9m338 => 0.7,
// Rams: a lot of speed, no warhead to speak of.
MissileKind::P1sun => 0.25,
},
}
}
/// How much a target takes before it comes apart: size, then what it carries.
pub fn toughness(kind: DroneKind) -> f64 {
let s = kind.spec();
0.2 + 0.2 * s.scale + 0.03 * s.warhead as f64
}
/// Chance of an air detonation: nothing without a warhead, rare for shells, likely when a strong
/// interceptor meets a small target.
const DET_BASE: f64 = 0.04;
const DET_SLOPE: f64 = 0.32;
const DET_FLOOR: f64 = 0.03;
const DET_CEIL: f64 = 0.75;
/// Of the rest, the share that breaks up instead of falling on.
const BREAK_BASE: f64 = 0.12;
const BREAK_SLOPE: f64 = 0.3;
const BREAK_CEIL: f64 = 0.8;
/// A broken-up target whose warhead section stays whole and falls on (and may cook off on landing).
pub const WARHEAD_SURVIVES: f64 = 0.3;
/// The relative strength of the hit against the target.
pub fn ratio(kind: DroneKind, imp: &Impact) -> f64 {
let speed = (0.8 + 0.05 * imp.rel_speed).clamp(0.8, 1.2);
imp.energy * speed / toughness(kind)
}
/// (chance of an air detonation, chance of a breakup) of a hit on `kind`; the rest falls on.
pub fn odds(kind: DroneKind, imp: &Impact) -> (f64, f64) {
let s = kind.spec();
let r = ratio(kind, imp);
let det = if s.decoy || s.warhead == 0 { 0.0 } else { (DET_BASE + DET_SLOPE * (r - 0.4)).clamp(DET_FLOOR, DET_CEIL) };
let share = (BREAK_BASE + BREAK_SLOPE * r).clamp(0.0, BREAK_CEIL);
(det, (1.0 - det) * share)
}
/// The outcome for a roll of 0..1.
pub fn choose(kind: DroneKind, imp: &Impact, roll: f64) -> Outcome {
let (det, brk) = odds(kind, imp);
if roll < det {
Outcome::AirDetonation
} else if roll < det + brk {
Outcome::Breakup
} else {
Outcome::DisabledFalling
}
}
/// The look of a kill by the target's warhead and the outcome: (fireball radius, ticks it burns,
/// loudness, sound). A bare decoy pops; a detonation lights the sky. The live raid and its replay
/// both take it from here.
pub fn look(kind: DroneKind, outcome: Outcome) -> (f32, u32, f32, &'static str) {
let w = kind.spec().warhead.min(8) as f32;
let size = 3.5 * (0.85 + 0.12 * w);
let life = (8 + (w as u32) / 2) * 9;
let gain = 1.0 + 0.06 * w;
match outcome {
Outcome::DisabledFalling => (size, life, gain, "airdef:blast.air"),
Outcome::Breakup => (size * 0.9, life, gain * 0.9, "airdef:blast.medium"),
Outcome::AirDetonation => (size * 1.9, life * 8 / 5, gain * 1.5, "airdef:blast.big"),
}
}
// ---- pieces -----------------------------------------------------------------------------------
/// Most pieces alive at once; a mass battle thins them out.
pub const PIECES_MAX: usize = 320;
/// Longest a piece lives, ticks (a replay's pieces end here too).
pub const PIECE_LIFE: u32 = 110;
/// One piece thrown off: its own velocity, spin, size, drag and life.
#[derive(Clone, Copy, Debug)]
pub struct Piece {
pub vel: DVec3,
pub spin: DQuat,
pub scale: f64,
pub drag: f64,
pub life: u32,
/// Trails smoke while it falls.
pub burns: bool,
}
/// The pieces of an outcome from `seed` (decorative randomness only), moving on with `base` (the
/// target's velocity). None for a fall.
pub fn pieces(seed: u64, outcome: Outcome, base: DVec3) -> Vec<Piece> {
let (n, speed, size, life) = match outcome {
Outcome::DisabledFalling => return Vec::new(),
Outcome::Breakup => (7, 0.35, 1.0, 70),
Outcome::AirDetonation => (12, 0.8, 0.8, 50),
};
let mut rng = Rng::new(seed ^ 0x9e37_79b9_7f4a_7c15);
(0..n)
.map(|_| {
let dir = DVec3::new(rng.s(), rng.s() * 0.8 + 0.2, rng.s()).normalize_or(DVec3::Y);
let v = base * if outcome == Outcome::Breakup { 0.55 } else { 0.3 } + dir * speed * (0.4 + 0.8 * rng.f());
let spin = DQuat::from_euler(glam::EulerRot::XYZ, rng.s() * 0.5, rng.s() * 0.5, rng.s() * 0.5);
Piece {
vel: v,
spin,
scale: size * (0.5 + 0.8 * rng.f()),
drag: 0.96 + 0.025 * rng.f(),
life: (life as f64 * (0.6 + 0.4 * rng.f())) as u32,
burns: rng.chance(0.6),
}
})
.collect()
}
/// One tick of a piece's flight: gravity, then drag (the replays and the live raid move the same).
pub fn fly(pos: &mut DVec3, vel: &mut DVec3, drag: f64) {
vel.y -= 0.05;
*vel *= drag;
*pos += *vel;
}
/// Where a piece is `age` ticks after it was thrown from `at`, as a pure function (for replays).
pub fn piece_at(at: DVec3, p: &Piece, age: u32) -> DVec3 {
let (mut pos, mut vel) = (at, p.vel);
for _ in 0..age.min(p.life) {
fly(&mut pos, &mut vel, p.drag);
}
pos
}
/// How a piece is turned `age` ticks after it was thrown (a replay's, from the start).
pub fn piece_rot(p: &Piece, age: u32) -> DQuat {
let mut rot = DQuat::IDENTITY;
for _ in 0..age.min(p.life) {
rot = p.spin * rot;
}
rot
}
#[cfg(test)]
mod tests {
use super::*;
fn shares(kind: DroneKind, imp: Impact, seeds: u64) -> [u32; 3] {
let mut n = [0u32; 3];
let mut rng = Rng::new(97);
for _ in 0..seeds {
let roll = rng.f();
n[match choose(kind, &imp, roll) {
Outcome::DisabledFalling => 0,
Outcome::Breakup => 1,
Outcome::AirDetonation => 2,
}] += 1;
}
n
}
// #97: the profiles are tendencies, not laws: shells mostly leave a falling hull, a strong interceptor
// against a small target often detonates it, and everything still can happen.
#[test]
fn outcomes_follow_the_weapon_and_the_target() {
let at = DVec3::ZERO;
let gun = shares(DroneKind::Shahed, Impact::gun(SamKind::Gepard, at, 10.0), 4000);
assert!(gun[0] > 2400 && gun[2] < 400, "gun against a Shahed: {gun:?}");
assert!(gun[1] > 0 && gun[2] > 0, "not a law: some break up, a few go off: {gun:?}");
let strong = shares(DroneKind::Shahed, Impact::missile(MissileKind::M77n6, at, 6.0), 4000);
assert!(strong[2] > 2000, "a strong interceptor against a small target: {strong:?}");
let weak = shares(DroneKind::Shahed, Impact::missile(MissileKind::M57e6, at, 5.0), 4000);
assert!(weak[2] < strong[2] && weak[2] > gun[2], "in between: {weak:?}");
// A big missile is tougher than a small drone for the same interceptor.
let big = shares(DroneKind::Iskander, Impact::missile(MissileKind::Pac3, at, 6.0), 4000);
let small = shares(DroneKind::Shahed, Impact::missile(MissileKind::Pac3, at, 6.0), 4000);
assert!(big[2] < small[2], "{big:?} {small:?}");
// Without a warhead nothing goes off: a decoy or a bus breaks up or falls.
for kind in [DroneKind::Gerbera, DroneKind::Oreshnik] {
assert_eq!(shares(kind, Impact::missile(MissileKind::M77n6, at, 6.0), 2000)[2], 0, "{kind:?}");
}
}
fn kill(seed: u64, shader: bool, imp: impl Fn(DVec3) -> Impact) -> (Outcome, usize, u64) {
let mut sim = Sim::new(seed);
sim.shader = shader;
let mut out = Out::default();
let sam = sim.id();
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
let at = sim.drones[0].pos;
let o = sim.shoot_down_by(0, sam, None, imp(at), &mut out).unwrap();
(o, sim.drones.len(), sim.rng.state())
}
// #97: the roll is combat randomness: the same with graphics on and off, and the drawing never moves it.
#[test]
fn the_outcome_is_the_same_with_graphics_on_and_off() {
for seed in 1..40 {
let strong = |at| Impact::missile(MissileKind::M77n6, at, 6.0);
assert_eq!(kill(seed, true, strong), kill(seed, false, strong), "seed {seed}");
let gun = |at| Impact::gun(SamKind::Gepard, at, 10.0);
assert_eq!(kill(seed, true, gun), kill(seed, false, gun), "seed {seed}");
}
}
// #97: a detonated warhead is spent: no wreck falls, so no second blast on landing; the kill counts once.
#[test]
fn a_detonated_target_does_no_ground_damage_and_counts_once() {
let mut found = 0;
for seed in 1..60 {
let mut sim = Sim::new(seed);
sim.damage = true;
let mut out = Out::default();
let mut ground = Ground::default();
ground.answer((-50..50).flat_map(|x| (-50..400).map(move |z| (x, z, 70))).collect(), 0);
let sam = sim.id();
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
let at = sim.drones[0].pos;
let o = sim.shoot_down_by(0, sam, Some(5), Impact::missile(MissileKind::M77n6, at, 6.0), &mut out).unwrap();
if o != Outcome::AirDetonation {
continue;
}
found += 1;
assert!(sim.drones.is_empty(), "the main mass is gone");
assert_eq!((sim.kills, sim.hits, sim.crashes), (1, 0, 0));
assert_eq!(sim.sams[0].kills, 1);
let mut cmds = out.commands.len();
for _ in 0..400 {
sim.tick += 1;
let mut o = Out::default();
sim.step_drones(&mut ground, None, &mut o);
sim.step_fx(&mut ground, &mut o);
cmds += o.commands.len();
assert!(o.sounds.iter().all(|s| !s.0.starts_with("airdef:blast")), "a second blast");
}
assert_eq!(cmds, 0, "no primed TNT for the land from a detonated warhead");
assert_eq!(sim.kills, 1);
}
assert!(found >= 10, "{found} detonations in 59 seeds");
}
// #97: a breakup throws pieces that have a life: they expire, and the number alive is bounded in a mass battle.
#[test]
fn pieces_expire_and_a_mass_battle_keeps_them_bounded() {
let mut sim = Sim::new(3);
let mut out = Out::default();
let mut ground = Ground::default();
for i in 0..150 {
let sam = sim.id();
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(f64::from(i) * 500.0, 70.0, 0.0), 0.0));
sim.add_drone(DroneKind::Shahed, DVec3::new(f64::from(i) * 500.0, 400.0, 300.0), DVec3::X, DVec3::new(9e5, 70.0, 0.0));
let at = sim.drones.last().unwrap().pos;
let idx = sim.drones.len() - 1;
let _ = sim.shoot_down_by(idx, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut out);
}
let pieces = |s: &Sim| s.fx.iter().filter(|(_, f)| matches!(f, Fx::Piece { .. })).count();
let n = pieces(&sim);
assert!(n > 0 && n <= PIECES_MAX, "{n} pieces");
for _ in 0..PIECE_LIFE + 1 {
sim.tick += 1;
sim.step_fx(&mut ground, &mut Out::default());
}
assert_eq!(pieces(&sim), 0, "pieces expire");
}
// #97: a breakup either keeps the warhead section (a wreck still falling, which may go off on landing by the
// existing roll) or leaves nothing to fall.
#[test]
fn a_breakup_keeps_the_warhead_section_only_sometimes() {
let (mut kept, mut gone) = (0, 0);
for seed in 1..200 {
let (o, drones, _) = kill(seed, true, |at| Impact::missile(MissileKind::M57e6, at, 5.0));
if o == Outcome::Breakup {
if drones == 1 {
kept += 1;
} else {
gone += 1;
}
}
}
assert!(kept > 0 && gone > kept, "kept {kept} gone {gone}");
}
// #97: a flight of a piece is a pure function of its age (so a replay moves it as the raid did).
#[test]
fn a_pieces_flight_is_the_same_stepped_or_computed() {
let ps = pieces(5, Outcome::AirDetonation, DVec3::new(0.5, 0.0, 0.2));
assert!(!ps.is_empty() && pieces(5, Outcome::DisabledFalling, DVec3::ZERO).is_empty());
let (mut pos, mut vel) = (DVec3::new(1.0, 100.0, 2.0), ps[0].vel);
for _ in 0..20 {
fly(&mut pos, &mut vel, ps[0].drag);
}
assert!(pos.distance(piece_at(DVec3::new(1.0, 100.0, 2.0), &ps[0], 20)) < 1e-9);
assert!(ps.iter().all(|p| p.life <= PIECE_LIFE));
}
}
+200 -14
View File
@@ -69,6 +69,9 @@ struct Item {
key: Option<(DroneKind, i32, i32, i32)>,
early: f64,
gap: f64,
/// The site it is a job of: the want (see `sites`) and its pad there.
want: usize,
pad: u32,
}
/// A raid being planned: its drones, the prices and routes so far, and the planned ones.
@@ -78,8 +81,12 @@ pub(super) struct Plan {
routes: HashMap<(DroneKind, i32, i32, i32), Vec<DVec3>>,
items: Vec<Item>,
next: usize,
/// (drone, flight time, how early it arrives, its wave's delay)
done: Vec<(Drone, f64, f64, f64)>,
/// (drone, flight time, how early it arrives, its wave's delay, its site)
done: Vec<(Drone, f64, f64, f64, u32)>,
/// The launch sites wanted, and whether they are settled; the ticks spent waiting for the game's answers.
wants: Vec<sites::Want>,
sited: bool,
site_wait: u32,
smart: bool,
approach: Approach,
total: u32,
@@ -242,7 +249,9 @@ impl Sim {
let smart = order.aim == Aim::Smart;
// A smart enemy comes from where its raids got through best.
let bearing = if smart { self.best_bearing() } else { self.rng.f() * TAU };
let second = bearing + std::f64::consts::PI * (0.6 + self.rng.f() * 0.6);
// The raid's sides (1, 2, or 4-5): its launch sites go there (#100).
let sides = sites::pick_sides(&mut self.rng, order.approach, bearing);
let mut wants: Vec<sites::Want> = Vec::new();
// One grid over the whole raid; each kind's prices are worked out once, routes are shared
// by the drones of a kind going from the same bearing to the same target.
let span = order.dist + 450.0;
@@ -252,25 +261,31 @@ impl Sim {
for (group, &(kind, count)) in order.kinds.iter().enumerate() {
let spec = kind.spec();
for _ in 0..count {
let a = match order.approach {
Approach::Sector | Approach::Waves => bearing + self.rng.s() * 0.35,
Approach::TwoSectors => (if self.rng.chance(0.5) { bearing } else { second }) + self.rng.s() * 0.3,
Approach::AllSides => self.rng.f() * TAU,
let side = match order.approach {
Approach::Sector | Approach::Waves => 0,
Approach::TwoSectors => usize::from(self.rng.chance(0.5)),
Approach::AllSides => (self.rng.f() * sides.len() as f64) as usize % sides.len(),
};
let a = sides[side];
let object = objects[(self.rng.f() * objects.len() as f64) as usize % objects.len()];
let (target, at_sam) = self.choose_target(kind, object, order.aim);
let aim = target + DVec3::new(self.rng.s() * 4.0, 0.0, self.rng.s() * 4.0);
let dist = spec.ballistic.map_or(order.dist, |far| far.max(order.dist)) + self.rng.f() * 50.0;
let height = if spec.ballistic.is_some() { 10.0 } else if spec.cruise > 0.0 { spec.cruise } else { 25.0 };
// Where it starts is provisional: the site's pad, once the site is placed.
let from = centre + DVec3::new(a.sin() * dist, height + self.rng.s() * 6.0, a.cos() * dist);
let mut d = self.make_drone(kind, from, horizontal(aim - from), aim);
let range = spec.ballistic.map_or(order.dist, |far| far.max(order.dist));
let want = sites::want_index(&mut wants, centre, side, a, sites::SiteClass::of(kind), range, order.dist.max(120.0));
let pad = wants[want].jobs;
wants[want].jobs += 1;
d.sector = ew::sector(a) as u8;
let routed = smart && spec.ballistic.is_none() && !spec.decoy && !at_sam.is_some_and(|_| kind == DroneKind::Lancet);
let key = routed.then(|| (kind, (a.rem_euclid(TAU) / 0.35) as i32, (target.x / 24.0) as i32, (target.z / 24.0) as i32));
// Decoys come 15 s ahead, the rest together; "waves" one kind after another.
let early = if spec.decoy { 300.0 } else { 0.0 };
let gap = if order.approach == Approach::Waves { group as f64 * 300.0 } else { 0.0 };
items.push(Item { d, from, aim, key, early, gap });
items.push(Item { d, from, aim, key, early, gap, want, pad });
}
}
self.plan = Some(Plan {
@@ -280,6 +295,9 @@ impl Sim {
items,
next: 0,
done: Vec::new(),
wants,
sited: false,
site_wait: 0,
smart,
approach: order.approach,
total,
@@ -297,6 +315,20 @@ impl Sim {
/// Works on the raid's plan a while (a tick's share), and launches it when it is ready.
pub(super) fn plan_step(&mut self, ground: Option<&mut Ground>, out: &mut Out) {
let Some(mut plan) = self.plan.take() else { return };
let mut ground = ground;
// First the launch sites: suitable ground the game has told of, or no raid (#100).
if !plan.sited {
let Some(g) = ground.as_deref_mut() else {
self.plan = Some(plan);
return;
};
if !self.settle_sites(&mut plan, g, out) {
if !plan.items.is_empty() {
self.plan = Some(plan);
}
return;
}
}
// A smart raid first learns the land under its grid: asked for in bulk, a thousand columns a tick, for
// up to two seconds (what the game has no land for stays unknown and costs nothing).
if !plan.mapped {
@@ -342,7 +374,7 @@ impl Sim {
let mut d = plan.items[i].d.clone();
// It starts over the land, not in it: a hill under the starting point lifts it.
let mut from = from;
if !plan.heights.is_empty() {
if !plan.heights.is_empty() && plan.wants[plan.items[i].want].found.is_none() {
let (gi, gj) = plan.grid.index(from);
let g = plan.heights[(gi * plan.grid.size + gj) as usize];
let floor = f64::from(g) + if spec.ballistic.is_some() { 10.0 } else { spec.clearance + 10.0 };
@@ -388,7 +420,7 @@ impl Sim {
Flight::Ballistic => (horizontal(aim - from).length() / spec.speed).max(30.0),
_ => path / spec.speed,
};
plan.done.push((d, time, plan.items[i].early, plan.items[i].gap));
plan.done.push((d, time, plan.items[i].early, plan.items[i].gap, plan.wants[plan.items[i].want].site));
plan.next += 1;
work += 1;
}
@@ -398,10 +430,13 @@ 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 (mut d, time, early, gap) in plan.done {
let arrive = plan.done.iter().map(|(_, t, e, g, _)| t + e - g).fold(0.0, f64::max);
for (mut d, time, early, gap, site) in plan.done {
d.smart = plan.smart;
let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
if site != 0 {
self.sites.job(d.id, site);
}
self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d));
}
self.wave = Some(WaveStats {
@@ -575,17 +610,156 @@ impl Sim {
}
/// A planned raid's drones take off when their time comes.
pub(super) fn release_queue(&mut self) {
pub(super) fn release_queue(&mut self, out: &mut Out) {
let now = self.tick;
if self.queue.iter().all(|q| q.0 > now) {
return;
}
let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now);
self.queue = wait;
for (_, d) in go {
let mut cancelled = 0;
for (t, d) in go {
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
match self.site_slot(d.id) {
sites::Slot::Free => {}
sites::Slot::Busy => {
self.queue.push((t, d));
continue;
}
sites::Slot::Gone => {
cancelled += 1;
continue;
}
sites::Slot::Go => {
let dir = d.vel.normalize_or_zero();
out.sound(d.kind.launch_sound(), d.pos, 1.0, 1.0);
out.particles("minecraft:flame", d.pos - dir * 1.5, 0.15, -dir * 0.1, 0.02, 4);
out.particles("minecraft:large_smoke", d.pos - dir * 2.5, 0.5, DVec3::new(0.0, 0.05, 0.0), 0.02, 4);
}
}
self.drones.push(d);
self.launched += 1;
}
if cancelled > 0 {
out.messages.push(format!("Пусковая площадка не запустила {cancelled}: нет запаса или уничтожена"));
}
}
/// Looks for the raid's launch sites. Returns whether they are settled (placed, or given up: drones whose
/// side has no suitable ground go to another side's site, or are cancelled with a message; a raid left with
/// nothing is cancelled whole).
fn settle_sites(&mut self, plan: &mut Plan, ground: &mut Ground, out: &mut Out) -> bool {
plan.site_wait += 1;
let mut keep: Vec<DVec3> = self.sams.iter().filter(|s| s.alive()).map(|s| s.pos).collect();
keep.extend(self.targets.iter().copied());
keep.push(plan.centre);
let last = plan.site_wait >= sites::SEARCH_TICKS;
if !sites::search(&mut plan.wants, ground, plan.centre, &keep, self.tick, last) {
return false;
}
use sites::Search;
// A side that found nothing hands its drones to a site of the same class and range on another side.
let mut moved = 0;
let mut dropped = 0;
let found: Vec<usize> = (0..plan.wants.len()).filter(|&i| plan.wants[i].state == Search::Found).collect();
let mut keep_items = Vec::new();
for mut it in std::mem::take(&mut plan.items) {
let w = &plan.wants[it.want];
if w.state != Search::Found {
let (class, range) = (w.class, w.ordered);
let near = |x: &sites::Want| x.class == class && (x.ordered - range).abs() < range * 0.35 + 30.0;
match found.iter().copied().find(|&i| near(&plan.wants[i])) {
Some(i) => {
it.want = i;
moved += 1;
}
None => {
dropped += 1;
continue;
}
}
}
keep_items.push(it);
}
plan.items = keep_items;
if moved + dropped > 0 {
out.messages.push(format!(
"Нет пригодной земли для части площадок: {moved} целей ушли с другой стороны, {dropped} отменено"
));
}
plan.total -= dropped;
if plan.items.is_empty() {
out.messages.push("Налёт отменён: нет пригодной известной земли для пусковых площадок".into());
return false;
}
// The pads, and the sites themselves; the old raid's sites go unless they still have jobs to fly.
let queued: Vec<u32> = self.queue.iter().map(|q| q.1.id).collect();
self.sites.retire(&queued);
for w in plan.wants.iter_mut() {
w.jobs = 0;
}
for it in plan.items.iter_mut() {
it.pad = plan.wants[it.want].jobs;
plan.wants[it.want].jobs += 1;
}
let mut shortened = false;
for w in plan.wants.iter_mut().filter(|w| w.state == Search::Found && w.jobs > 0) {
let mut pos = w.found.unwrap_or_default();
if w.water {
pos.y += sites::DECK;
}
shortened |= w.range < w.ordered - if w.class == sites::SiteClass::Battery { 1.0 } else { 75.0 };
w.site = self.sites.add(sites::LaunchSite {
id: 0,
side: w.side as u8,
sector: ew::sector(w.bearing) as u8,
bearing: w.bearing,
class: w.class,
pos,
range: w.range,
water: w.water,
stock: w.jobs,
planned: w.jobs,
alive: true,
next_free: 0,
});
}
if shortened {
out.messages.push("Пуск ближе обычного: дальше нет известной земли или воды".into());
}
// Each drone onto its pad, climbing off the ramp or leaving the tube (ballistic ones aim as always).
for it in plan.items.iter_mut() {
let w = &plan.wants[it.want];
let Some(site) = self.sites.get(w.site) else { continue };
let from = site.muzzle(it.pad);
let spec = it.d.kind.spec();
it.from = from;
it.d.pos = from;
if spec.ballistic.is_some() {
let flat = horizontal(it.d.target - from);
let time = (flat.length() / spec.speed).max(30.0);
it.d.vel = flat / time + DVec3::Y * ((it.d.target.y - from.y + 0.5 * GRAVITY * time * time) / time);
} else {
let pitch = site.class.pitch();
let dir = horizontal(it.d.target - from).normalize_or(site.inward());
it.d.vel = (dir * pitch.cos() + DVec3::Y * pitch.sin()) * spec.speed;
}
it.d.sector = site.sector;
}
let n = self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)).count();
let barges = self.sites.list.iter().filter(|s| s.water && plan.wants.iter().any(|w| w.site == s.id)).count();
out.messages.push(format!("Пусковые площадки противника: {n}{}", if barges > 0 { format!(" (на баржах: {barges})") } else { String::new() }));
// Where they are is the enemy's secret, unless the sandbox's true picture is on.
if self.truth {
for site in self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)) {
out.messages.push(format!(
"Площадка #{} ({:?}, сторона {}): {:.0} {:.0} {:.0}, запас {}",
site.id, site.class, site.side, site.pos.x, site.pos.y, site.pos.z, site.stock
));
}
}
plan.sited = true;
true
}
/// When the raid is over: what it cost both sides.
@@ -632,6 +806,7 @@ fn route_length(from: DVec3, route: &[DVec3], to: DVec3) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
use super::sites::testland::{Land, serve};
#[test]
fn a_smart_raid_plans_and_flies_in_time() {
@@ -641,8 +816,10 @@ mod tests {
sim.sams.push(Sam::new(id, kind, DVec3::new(-30.0 + 15.0 * i as f64, 70.0, 0.0), 0.0));
}
let mut ground = Ground::default();
let land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..30 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let t = std::time::Instant::now();
@@ -655,6 +832,7 @@ mod tests {
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, -40.0)], &mut out);
let mut ticks = 0;
while sim.planning() {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
ticks += 1;
}
@@ -663,6 +841,7 @@ mod tests {
for k in 0..4000 {
let s = std::time::Instant::now();
out = Out::default();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
assert!(s.elapsed().as_millis() < 200, "tick {k} took {:?}", s.elapsed());
}
@@ -679,8 +858,10 @@ mod tests {
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 land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..30 {
serve(&mut ground, &land, sim.tick);
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));
@@ -697,6 +878,7 @@ mod tests {
#[cfg(test)]
mod big {
use super::*;
use super::sites::testland::{Land, serve};
#[test]
fn sixty_one_systems_and_a_big_smart_raid() {
@@ -710,8 +892,10 @@ mod big {
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
sim.target_health.push(100.0);
let mut ground = Ground::default();
let land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..200 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let mut w = crate::wave::WaveConfig::default();
@@ -725,6 +909,7 @@ mod big {
let mut slowest = std::time::Duration::ZERO;
while sim.planning() {
let s = std::time::Instant::now();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
slowest = slowest.max(s.elapsed());
ticks += 1;
@@ -734,6 +919,7 @@ mod big {
for _ in 0..3000 {
let s = std::time::Instant::now();
out = Out::default();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
worst = worst.max(s.elapsed());
}
+75 -6
View File
@@ -168,8 +168,9 @@ pub struct Track {
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum EventKind {
/// A drone shot down: by which system (its index then), with a missile or the guns.
Kill { drone: u32, kind: DroneKind, sam: u32, sam_kind: SamKind, sam_index: usize, missile: Option<u32> },
/// A drone shot down: by which system (its index then), with a missile or the guns, and how it ended
/// (rolled once, live; a replay reads it and never rolls).
Kill { drone: u32, kind: DroneKind, sam: u32, sam_kind: SamKind, sam_index: usize, missile: Option<u32>, outcome: Outcome },
/// A drone's warhead went off where it aimed: at an object, a system, or the land.
Hit { drone: u32, kind: DroneKind, power: u32, object: Option<usize>, sam: Option<(SamKind, usize)> },
/// A system destroyed.
@@ -331,8 +332,8 @@ pub struct ReplayFire {
/// The look of an interception by the drone's warhead: (fireball radius, ticks it burns, loudness).
/// A bare decoy pops; a big warhead lights the sky. The same in the live raid and its replay.
pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
let w = kind.spec().warhead.min(8) as f32;
(3.5 * (0.85 + 0.12 * w), (8 + (w as u32) / 2) * 9, 1.0 + 0.06 * w)
let (size, life, gain, _) = outcome::look(kind, Outcome::DisabledFalling);
(size, life, gain)
}
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
@@ -340,8 +341,8 @@ pub fn blast_spec(kind: EventKind) -> (f32, u32) {
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
const LIFE: [u32; 5] = [11, 11, 18, 22, 26];
match kind {
EventKind::Kill { kind, .. } => {
let (size, life, _) = kill_look(kind);
EventKind::Kill { kind, outcome, .. } => {
let (size, life, _, _) = outcome::look(kind, outcome);
(size, life)
}
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
@@ -581,6 +582,7 @@ impl Sim {
/// Draws the recording's drones and interceptors as they were at `t`, as ghosts: ids from `base`.
pub fn draw_ghosts(&self, rec: &Recording, t: f64, base: u32, out: &mut Out) {
let base_id = base;
for (k, tr) in rec.tracks.iter().enumerate() {
let Some(pos) = tr.at(rec.origin, t) else { continue };
let dir = tr.dir(rec.origin, t);
@@ -660,6 +662,31 @@ impl Sim {
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
}
}
// The pieces a breakup or an air detonation threw off, from the recorded outcome (never a new roll): the same
// pieces the live raid made, moved by the same flight, a pure function of the time.
for (k, e) in rec.events.iter().enumerate() {
let EventKind::Kill { drone, outcome, .. } = e.kind else { continue };
let age = t - e.at as f64;
if outcome == Outcome::DisabledFalling || !(0.0..outcome::PIECE_LIFE as f64).contains(&age) {
continue;
}
let base = rec
.track(drone)
.and_then(|tr| Some((tr.at(rec.origin, e.at as f64)? - tr.at(rec.origin, e.at as f64 - STEP as f64)?) / STEP as f64))
.unwrap_or(DVec3::ZERO);
let seed = (k as u64).wrapping_mul(0x2545_f491_4f6c_dd1d) ^ e.at as u64;
for (n, p) in outcome::pieces(seed, outcome, base).iter().enumerate() {
if age as u32 >= p.life {
continue;
}
let at = outcome::piece_at(e.pos, p, age as u32);
let id = base_id + 0x0060_0000 + (k as u32 * 16 + n as u32) * PARTS;
out.model(id, "airdef:debris", at, outcome::piece_rot(p, age as u32), p.scale, false);
if p.burns && (age as u32) % 3 == 0 {
out.particles("minecraft:large_smoke", at, 0.1, DVec3::new(0.0, 0.03, 0.0), 0.01, 1);
}
}
}
// Blasts: with the shader effects on, the post pass and the HD lights draw them from
// `replay_blasts` (the same as the live ones); the models are the fallback without them.
if self.shader {
@@ -793,4 +820,46 @@ mod tests {
assert_eq!(r.world_tick(50.0), Some(1040 + 10));
assert_eq!(r.world_tick(70.0), Some(1100 + 10));
}
// #97: the kill's outcome is a recorded fact: a replay draws the pieces of that outcome and rolls nothing;
// an event without one (an old recording) is the plain fall it always was.
#[test]
fn a_replay_shows_the_recorded_outcome() {
let mut sim = Sim::new(5);
sim.rec = Some(Recording::new(1, &sim));
let sam = sim.id();
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
let at = sim.drones[0].pos;
let live = sim.shoot_down_by(0, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut Out::default()).unwrap();
let rec = sim.rec.take().unwrap();
let recorded = rec.events.iter().find_map(|e| if let EventKind::Kill { outcome, .. } = e.kind { Some(outcome) } else { None });
assert_eq!(recorded, Some(live), "the live outcome is what was recorded");
let pieces = |rec: &Recording, t: f64| {
let mut out = Out::default();
sim.draw_ghosts(rec, t, 0x5000_0000, &mut out);
out.models.iter().filter(|m| m.name == "airdef:debris").count()
};
let at_tick = rec.events[0].at as f64;
// The same frame twice is the same (a pure function of the time), and the pieces are the outcome's.
let mut forced = Recording { events: rec.events.clone(), ..Recording::blank(200) };
for (outcome, want) in [(Outcome::AirDetonation, true), (Outcome::Breakup, true), (Outcome::DisabledFalling, false)] {
if let EventKind::Kill { outcome: o, .. } = &mut forced.events[0].kind {
*o = outcome;
}
assert_eq!(pieces(&forced, at_tick + 5.0), pieces(&forced, at_tick + 5.0));
assert_eq!(pieces(&forced, at_tick + 5.0) > 0, want, "{outcome:?}");
assert_eq!(pieces(&forced, at_tick + f64::from(outcome::PIECE_LIFE) + 1.0), 0, "the pieces are gone in time");
}
// Its flash and sound are the outcome's too.
let spec = |o| {
let mut e = rec.events[0];
if let EventKind::Kill { outcome, .. } = &mut e.kind {
*outcome = o;
}
blast_spec(e.kind)
};
assert!(spec(Outcome::AirDetonation).0 > spec(Outcome::DisabledFalling).0);
assert_eq!(spec(Outcome::default()).0, kill_look(DroneKind::Shahed).0, "an old recording looks as it did");
}
}
+4 -2
View File
@@ -473,7 +473,7 @@ fn pending_exact_answer_does_not_let_the_fuse_kill_through_a_wall() {
ground.world.answer(answers, sim.tick);
}
}
(first, sim.kills, sim.drones[0].alive())
(first, sim.kills, sim.drones.first().is_some_and(Drone::alive))
};
for seed in 1..=10 {
// Just behind the wall, inside the fuse's reach of a missile held at its face.
@@ -1348,11 +1348,13 @@ fn raids_have_the_same_strike_accuracy_as_manual_launches() {
sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0));
let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects };
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default());
// (The raid's launch sites need land the game has told of: a flat one is served.)
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
for _ in 0..40 {
if !sim.planning() {
break;
}
sim.step(&mut Ground::default(), None, &mut Out::default());
sites::testland::step(&mut sim, &mut ground, &land, &mut Out::default());
}
assert!(!sim.queue.is_empty() && sim.queue.iter().all(|(_, d)| d.err != DVec3::ZERO), "raid drones without a navigation error");
let mut d = sim.make_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, -500.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
+832
View File
@@ -0,0 +1,832 @@
//! Launch sites (#100): where a raid really starts from. A raid picks its sides (one, two, or four to five),
//! looks for suitable ground on each side (only land the game has told of: flat, open to the sky, clear in
//! front, away from the defence), and sets up a site there: a ramp for the drones, a launcher vehicle for the
//! cruise missiles, a battery for the ballistic ones (at their own range). The raid's drones are jobs of the
//! sites: each one leaves its site, one at a time, from the stock the site holds. Ground nobody has told of is
//! never suitable, and nothing is force-loaded: a side without suitable ground waits for the game's answers
//! for a few seconds, and is then given up with a message.
use super::*;
/// The most candidate places tried for one site, and how long (ticks) a raid waits for the game's answers.
pub const MAX_TRIES: usize = 90;
pub const SEARCH_TICKS: u32 = 300;
/// Candidates asked about ahead of the one being waited for.
const PREFETCH: usize = 3;
/// The footprint's samples (offsets in blocks), the most the ground may differ over it, and how far from
/// the defence and the objects a site must be.
const FOOTPRINT: [(f64, f64); 9] = [(0.0, 0.0), (8.0, 0.0), (-8.0, 0.0), (0.0, 8.0), (0.0, -8.0), (6.0, 6.0), (6.0, -6.0), (-6.0, 6.0), (-6.0, -6.0)];
const FLAT: i32 = 2;
const KEEP_OUT: f64 = 45.0;
/// What a site is for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SiteClass {
/// Drones: a catapult ramp.
Ramp,
/// Cruise and supersonic missiles: a launcher vehicle.
Launcher,
/// Ballistic missiles: a heavier launcher, far out.
Battery,
}
impl SiteClass {
pub fn of(kind: DroneKind) -> SiteClass {
let spec = kind.spec();
if spec.ballistic.is_some() {
SiteClass::Battery
} else if matches!(spec.class, Class::Drone | Class::Jet) {
SiteClass::Ramp
} else {
SiteClass::Launcher
}
}
/// Whether a site of this class can launch this kind.
pub fn supports(self, kind: DroneKind) -> bool {
SiteClass::of(kind) == self
}
/// Ticks between two launches from one site.
pub fn cadence(self) -> u64 {
match self {
SiteClass::Ramp => 3,
SiteClass::Launcher => 8,
SiteClass::Battery => 20,
}
}
fn slots(self) -> u32 {
match self {
SiteClass::Ramp => 4,
SiteClass::Launcher => 3,
SiteClass::Battery => 2,
}
}
fn spacing(self) -> f64 {
match self {
SiteClass::Ramp => 4.0,
SiteClass::Launcher => 9.0,
SiteClass::Battery => 12.0,
}
}
fn model(self) -> &'static str {
match self {
SiteClass::Ramp => "airdef:launch_ramp",
SiteClass::Launcher | SiteClass::Battery => "airdef:launcher_truck",
}
}
fn scale(self) -> f64 {
match self {
SiteClass::Ramp => 1.4,
SiteClass::Launcher => 1.5,
SiteClass::Battery => 1.9,
}
}
/// The barge's scale: its deck is 3 blocks a side at 1.
fn barge(self) -> f64 {
match self {
SiteClass::Ramp => 4.5,
_ => 7.0,
}
}
/// Where a drone leaves the kit: blocks ahead of its centre, and up from the ground.
fn muzzle(self) -> (f64, f64) {
match self {
SiteClass::Ramp => (1.8, 1.7),
SiteClass::Launcher => (2.6, 3.4),
SiteClass::Battery => (3.2, 4.2),
}
}
/// How steeply a drone leaves it (radians).
pub fn pitch(self) -> f64 {
match self {
SiteClass::Ramp => 0.35,
SiteClass::Launcher => 0.6,
SiteClass::Battery => 0.9,
}
}
}
/// One launch site of a raid: stable id, side, where, what it can launch, what is left, and when it may launch next.
#[derive(Clone, Debug)]
pub struct LaunchSite {
pub id: u32,
/// Which side of the raid it is on (0-4) and the enemy's sector of it.
pub side: u8,
pub sector: u8,
pub bearing: f64,
pub class: SiteClass,
/// The ground at its centre (the top of the surface), and how far from the raid's centre it is.
pub pos: DVec3,
pub range: f64,
/// On a barge over open water (its `pos` is the water's surface plus the deck).
pub water: bool,
/// Rounds left, and the jobs given to it (also how many pads are drawn).
pub stock: u32,
pub planned: u32,
pub alive: bool,
pub next_free: u64,
}
impl LaunchSite {
/// The direction a launch goes: from the site in, towards the raid's centre.
pub fn inward(&self) -> DVec3 {
DVec3::new(-self.bearing.sin(), 0.0, -self.bearing.cos())
}
fn lateral(&self) -> DVec3 {
DVec3::new(self.bearing.cos(), 0.0, -self.bearing.sin())
}
/// The base of pad `i` on the ground.
pub fn pad(&self, i: u32) -> DVec3 {
let n = self.class.slots();
self.pos + self.lateral() * ((f64::from(i % n) - f64::from(n - 1) / 2.0) * self.class.spacing())
}
/// Where a drone of pad `i` leaves the kit.
pub fn muzzle(&self, i: u32) -> DVec3 {
let (ahead, up) = self.class.muzzle();
self.pad(i) + self.inward() * ahead + DVec3::Y * up
}
pub fn supports(&self, kind: DroneKind) -> bool {
self.class.supports(kind)
}
}
/// The sites in play and the launch jobs given to them: (drone id, site id) for each drone still in the queue.
#[derive(Default)]
pub struct Sites {
pub list: Vec<LaunchSite>,
next: u32,
jobs: Vec<(u32, u32)>,
}
/// What a site says about a drone due to leave.
pub(super) enum Slot {
/// Not a site's job (a manual launch, an old save): off it goes.
Free,
Go,
/// The site launched a moment ago.
Busy,
/// The site is gone or empty: the job is cancelled.
Gone,
}
impl Sites {
pub fn get(&self, id: u32) -> Option<&LaunchSite> {
self.list.iter().find(|s| s.id == id)
}
/// Jobs waiting for a site.
pub fn pending(&self, id: u32) -> usize {
self.jobs.iter().filter(|j| j.1 == id).count()
}
/// The site a queued drone is a job of.
pub fn site_of(&self, drone: u32) -> Option<u32> {
self.jobs.iter().find(|j| j.0 == drone).map(|j| j.1)
}
pub fn clear(&mut self) {
self.list.clear();
self.jobs.clear();
}
pub(super) fn add(&mut self, mut site: LaunchSite) -> u32 {
self.next += 1;
site.id = self.next;
let id = site.id;
self.list.push(site);
id
}
pub(super) fn job(&mut self, drone: u32, site: u32) {
self.jobs.push((drone, site));
}
/// The old raid's sites go, but not those with jobs still to fly.
pub(super) fn retire(&mut self, queued: &[u32]) {
self.jobs.retain(|j| queued.contains(&j.0));
let jobs = &self.jobs;
self.list.retain(|s| jobs.iter().any(|j| j.1 == s.id));
}
fn take(&mut self, drone: u32, now: u64) -> Slot {
let Some(j) = self.jobs.iter().position(|j| j.0 == drone) else { return Slot::Free };
let site = self.jobs[j].1;
let Some(s) = self.list.iter_mut().find(|s| s.id == site) else {
self.jobs.swap_remove(j);
return Slot::Gone;
};
if !s.alive || s.stock == 0 {
self.jobs.swap_remove(j);
return Slot::Gone;
}
if s.next_free > now {
return Slot::Busy;
}
s.stock -= 1;
s.next_free = now + s.class.cadence();
self.jobs.swap_remove(j);
Slot::Go
}
}
// ---------------------------------------------------------------- choosing sides and places
/// The sides of a raid as bearings: one, two far apart (108° or more), or four to five spread round (at least
/// 43° apart). The seed decides which.
pub fn pick_sides(rng: &mut Rng, approach: Approach, bearing: f64) -> Vec<f64> {
match approach {
Approach::Sector | Approach::Waves => vec![bearing],
Approach::TwoSectors => vec![bearing, bearing + std::f64::consts::PI * (0.6 + rng.f() * 0.6)],
Approach::AllSides => {
let n = if rng.chance(0.5) { 4 } else { 5 };
let step = TAU / f64::from(n);
(0..n).map(|k| bearing + f64::from(k) * step + rng.s() * 0.2 * step).collect()
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Search {
Open,
Found,
Failed,
}
/// A site wanted by a raid: its side and class, the places to try in order, and how the search stands.
pub(super) struct Want {
pub side: usize,
pub bearing: f64,
pub class: SiteClass,
pub range: f64,
/// The range ordered (`range` is where the site stood in the end).
pub ordered: f64,
cands: Vec<(DVec3, bool)>,
cursor: usize,
pub found: Option<DVec3>,
pub water: bool,
pub state: Search,
pub jobs: u32,
pub site: u32,
}
/// The want of this side, class and range, made if there is none yet.
pub(super) fn want_index(wants: &mut Vec<Want>, centre: DVec3, side: usize, bearing: f64, class: SiteClass, range: f64, lo: f64) -> usize {
if let Some(i) = wants.iter().position(|w| w.side == side && w.class == class && (w.range - range).abs() < 1.0) {
return i;
}
let out = DVec3::new(bearing.sin(), 0.0, bearing.cos());
let lat = DVec3::new(bearing.cos(), 0.0, -bearing.sin());
// In order: known land at the ordered range (round it for most, the ordered range for a ballistic battery),
// known land nearer in steps, then a barge on open water at the ordered range, then on water nearer.
let near = |from: f64| -> Vec<f64> {
let min = if class == SiteClass::Battery { lo.min(from).max(80.0) } else { (from * 0.3).max(80.0) };
(1..=6).map(|i| from - (from - min) * f64::from(i) / 6.0).collect()
};
let round: Vec<f64> = if class == SiteClass::Battery {
vec![range]
} else {
[0.0, 24.0, -24.0, 48.0, -48.0, 72.0, -72.0].iter().map(|d| (range + d).max(KEEP_OUT)).collect()
};
let laterals: &[f64] = &[0.0, 20.0, -20.0];
let mut cands = Vec::new();
for water in [false, true] {
for r in round.iter().copied().chain(near(range)) {
for l in laterals {
cands.push((centre + out * r + lat * *l, water));
}
}
}
cands.truncate(MAX_TRIES);
wants.push(Want { side, bearing, class, range, ordered: range, cands, cursor: 0, found: None, water: false, state: Search::Open, jobs: 0, site: 0 });
wants.len() - 1
}
enum Verdict {
/// The game hasn't told enough yet.
Pending,
Bad,
/// Suitable: the surface at its centre, and whether it is water's.
Good(DVec3, bool),
}
/// Whether a site fits at `p` (x, z): every sample of its footprint is land the game told of and within
/// `FLAT` blocks of the others, the sky above is open, the way ahead is clear, and nothing of the defence is near.
fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64) -> Verdict {
if p.x.abs() > 29_000_000.0 || p.z.abs() > 29_000_000.0 || keep.iter().any(|k| horizontal(*k - p).length() < KEEP_OUT) {
return Verdict::Bad;
}
let (mut lo, mut hi, mut pending) = (i32::MAX, i32::MIN, false);
for (dx, dz) in FOOTPRINT {
match ground.column((p.x + dx).floor() as i32, (p.z + dz).floor() as i32, 256) {
None => pending = true,
// No land to tell of (the sea, the void): never a site.
Some(None) => return Verdict::Bad,
Some(Some(h)) => {
lo = lo.min(h as i32);
hi = hi.max(h as i32);
}
}
}
if pending {
return Verdict::Pending;
}
if hi - lo > FLAT {
return Verdict::Bad;
}
let top = f64::from(hi);
let at = DVec3::new(p.x.floor() + 0.5, top, p.z.floor() + 0.5);
// The game's height is the top of what blocks motion *or holds a fluid*: the block under the surface is
// solid at the footprint's centre and edges (land), or at none of them (water); a shore is neither.
let mut solid = 0;
for (dx, dz) in FOOTPRINT.iter().take(5) {
let c = DVec3::new((p.x + dx).floor() + 0.5, top, (p.z + dz).floor() + 0.5);
match ground.world.sweep(c + DVec3::Y * 0.5, c - DVec3::Y * 0.5, false, now) {
world::Sweep::Hit { .. } => solid += 1,
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { .. } => return Verdict::Bad,
}
}
if solid != 0 && solid != 5 {
return Verdict::Bad;
}
let up = ground.world.sweep(at + DVec3::Y * 30.0, at + DVec3::Y * 1.0, false, now);
let ahead = ground.world.sweep(at + DVec3::Y * 3.0, at + inward * 14.0 + DVec3::Y * 3.0, false, now);
for s in [up, ahead] {
match s {
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { .. } | world::Sweep::Hit { .. } => return Verdict::Bad,
}
}
Verdict::Good(at, solid == 0)
}
/// Works on the open wants: each tries its places in order, a place the game hasn't told enough of holds the
/// search (the next few are asked about meanwhile), and a bad place is passed over. After `last` nothing waits.
/// Returns whether every want is settled.
pub(super) fn search(wants: &mut [Want], ground: &mut Ground, centre: DVec3, keep: &[DVec3], now: u64, last: bool) -> bool {
let mut all = true;
for k in 0..wants.len() {
if wants[k].state != Search::Open {
continue;
}
// Two sites never share a place: the others' kit is in the way.
let mut keep: Vec<DVec3> = keep.to_vec();
keep.extend(wants.iter().filter_map(|o| o.found));
let w = &mut wants[k];
let inward = DVec3::new(-w.bearing.sin(), 0.0, -w.bearing.cos());
loop {
if w.cursor >= w.cands.len() {
w.state = Search::Failed;
break;
}
let (cand, water) = w.cands[w.cursor];
match judge(ground, cand, inward, &keep, now) {
Verdict::Bad => w.cursor += 1,
// Land when land was asked for, a barge when water was.
Verdict::Good(_, got) if got != water => w.cursor += 1,
Verdict::Good(at, _) => {
w.found = Some(at);
w.water = water;
w.range = horizontal(at - centre).length();
w.state = Search::Found;
break;
}
Verdict::Pending => {
for k in 1..=PREFETCH {
if let Some(&(c, _)) = w.cands.get(w.cursor + k) {
let _ = judge(ground, c, inward, &keep, now);
}
}
if last {
w.state = Search::Failed;
} else {
all = false;
}
break;
}
}
}
}
all
}
// ---------------------------------------------------------------- in the sim
/// Id base of the kit's models.
const KIT: u32 = 0x7400_0000;
/// The barge's deck over the water.
pub const DECK: f64 = 0.3;
impl Sim {
/// Takes site `id` out of play (destroyed): its stock is gone and its queued jobs are cancelled. Returns how
/// many. (The counter-strike, #101, uses this.)
pub fn destroy_site(&mut self, id: u32) -> u32 {
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id) else { return 0 };
s.alive = false;
s.stock = 0;
let ids: Vec<u32> = self.sites.jobs.iter().filter(|j| j.1 == id).map(|j| j.0).collect();
self.queue.retain(|q| !ids.contains(&q.1.id));
self.sites.jobs.retain(|j| j.1 != id);
ids.len() as u32
}
/// Asks a site whether a queued drone may leave now.
pub(super) fn site_slot(&mut self, drone: u32) -> Slot {
let now = self.tick;
self.sites.take(drone, now)
}
/// Draws every site's kit: a ramp or launcher per pad in use, turned to the way the launches go.
pub(super) fn draw_sites(&self, out: &mut Out) {
for s in self.sites.list.iter().filter(|s| s.alive) {
let rot = DQuat::from_rotation_y(heading_of(s.inward()));
let sc = s.class.scale();
if s.water {
let b = s.class.barge();
out.model(KIT + (s.id * 8 + 7) * PARTS, "airdef:barge", s.pos - DVec3::Y * (DECK + 0.8 - 0.5 * b), rot, b, false);
}
for i in 0..s.planned.min(s.class.slots()) {
let id = KIT + (s.id * 8 + i) * PARTS;
out.model(id, s.class.model(), s.pad(i) + DVec3::Y * 0.5 * sc, rot, sc, false);
}
}
}
}
#[cfg(test)]
pub(super) mod testland {
use super::*;
/// Land from a height function; blocks below it are solid, and the game's height map says the same.
pub struct Land<F: Fn(i32, i32) -> i32>(pub F);
impl<F: Fn(i32, i32) -> i32> world::WorldSource for Land<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < (self.0)(x, z))
}
}
/// What the game answers a tick after the sim asked: heights, and the sections.
pub fn serve<F: Fn(i32, i32) -> i32>(ground: &mut Ground, land: &Land<F>, now: u64) {
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (land.0)(x, z))).collect();
ground.answer(columns, now);
let sections = ground.world.take_wanted(now);
ground.world.serve(land, sections, now);
}
pub fn step<F: Fn(i32, i32) -> i32>(sim: &mut Sim, ground: &mut Ground, land: &Land<F>, out: &mut Out) {
serve(ground, land, sim.tick);
sim.step(ground, None, out);
}
}
#[cfg(test)]
mod tests {
use super::testland::*;
use super::*;
fn sep(a: f64, b: f64) -> f64 {
angle_diff(a, b).abs().to_degrees()
}
fn min_sep(sides: &[f64]) -> f64 {
let mut m = 360.0f64;
for i in 0..sides.len() {
for j in 0..i {
m = m.min(sep(sides[i], sides[j]));
}
}
m
}
#[test]
fn sector_modes_give_one_two_or_four_to_five_sides_apart() {
let mut fours = 0;
let mut fives = 0;
for seed in 1..200u64 {
for approach in Approach::ALL {
let mut rng = Rng::new(seed);
let b = rng.f() * TAU;
let sides = pick_sides(&mut rng, approach, b);
match approach {
Approach::Sector | Approach::Waves => assert_eq!(sides.len(), 1),
Approach::TwoSectors => {
assert_eq!(sides.len(), 2);
assert!(min_sep(&sides) >= 100.0, "two sectors only {:.0} deg apart", min_sep(&sides));
}
Approach::AllSides => {
assert!((4..=5).contains(&sides.len()), "{} sides", sides.len());
assert!(min_sep(&sides) >= 40.0, "all sides: two only {:.0} deg apart", min_sep(&sides));
if sides.len() == 4 {
fours += 1;
} else {
fives += 1;
}
}
}
}
}
assert!(fours > 20 && fives > 20, "the seed should pick both 4 and 5: {fours} / {fives}");
}
fn flat() -> Land<impl Fn(i32, i32) -> i32> {
Land(|_, _| 64)
}
fn order(approach: Approach, dist: f64) -> RaidOrder {
RaidOrder {
kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Gerbera, 3), (DroneKind::Kh101, 3), (DroneKind::Iskander, 2)],
approach,
dist,
aim: Aim::Objects,
}
}
/// Orders a raid over `land` and plans it to the end; returns the ticks it took.
fn plan<F: Fn(i32, i32) -> i32>(sim: &mut Sim, ground: &mut Ground, land: &Land<F>, o: &RaidOrder, out: &mut Out) -> u32 {
sim.order_raid(o, &[DVec3::new(0.0, 70.0, 0.0)], out);
let mut n = 0;
while sim.planning() {
step(sim, ground, land, out);
n += 1;
assert!(n < 1000, "the raid never finished planning");
}
n
}
#[test]
fn sites_per_sector_mode_launch_every_drone_from_a_site_on_a_side() {
for (approach, sides) in [(Approach::Sector, 1usize), (Approach::TwoSectors, 2), (Approach::AllSides, 5), (Approach::Waves, 1)] {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(21);
plan(&mut sim, &mut ground, &land, &order(approach, 300.0), &mut out);
let mut sides_used: Vec<u8> = sim.sites.list.iter().map(|s| s.side).collect();
sides_used.sort_unstable();
sides_used.dedup();
match approach {
Approach::AllSides => assert!((4..=5).contains(&sides_used.len()), "{} sides", sides_used.len()),
_ => assert_eq!(sides_used.len(), sides, "{approach:?}"),
}
// No hidden extra position: at most a site per class on a side (ramp, launcher, one battery per range).
assert!(sim.sites.list.len() <= sides_used.len() * 3, "{} sites", sim.sites.list.len());
// Every drone has a site that supports its kind.
assert_eq!(sim.queue.len() + sim.drones.len(), 14, "{:?}", out.messages);
for (_, d) in &sim.queue {
let site = sim.sites.site_of(d.id).and_then(|id| sim.sites.get(id)).expect("a drone without a source site");
assert!(site.supports(d.kind), "{:?} from a {:?}", d.kind, site.class);
// It waits on the site's pad, over its ground.
assert!(horizontal(d.pos - site.pos).length() < 25.0, "{:?} is {:.0} away from its site", d.kind, horizontal(d.pos - site.pos).length());
assert!(d.pos.y > site.pos.y && d.pos.y < site.pos.y + 6.0);
}
for (i, a) in sim.sites.list.iter().enumerate() {
for b in &sim.sites.list[..i] {
assert!(horizontal(a.pos - b.pos).length() >= KEEP_OUT, "two sites on one place");
}
}
// Stock and jobs agree; ground is real: flat at 64.
for s in &sim.sites.list {
assert_eq!(s.stock as usize, sim.sites.pending(s.id));
assert_eq!(s.pos.y, 64.0);
}
}
}
#[test]
fn ballistic_sites_keep_their_own_range_and_ramps_the_ordered_one() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(4);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
let centre = DVec3::new(0.0, 70.0, 0.0);
for s in &sim.sites.list {
let r = horizontal(s.pos - centre).length();
match s.class {
SiteClass::Battery => assert!(r > 990.0, "a battery only {r:.0} out"),
_ => assert!((200.0..=400.0).contains(&r), "{:?} {r:.0} out", s.class),
}
}
}
#[test]
fn launches_start_at_the_site_with_a_real_climb_and_in_turn() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(9);
plan(&mut sim, &mut ground, &land, &order(Approach::TwoSectors, 300.0), &mut out);
let jobs: HashMap<u32, u32> = sim.queue.iter().map(|(_, d)| (d.id, sim.sites.site_of(d.id).unwrap())).collect();
// One or two may have left in the tick the plan was ready.
let early = sim.drones.len();
let stock: u32 = sim.sites.list.iter().map(|s| s.stock).sum();
assert_eq!(stock as usize + early, 14);
let mut seen = HashMap::new();
let mut last_launch: HashMap<u32, u64> = HashMap::new();
for _ in 0..4000 {
step(&mut sim, &mut ground, &land, &mut out);
for d in sim.drones.iter().filter(|d| d.age == 1) {
let site = sim.sites.get(jobs[&d.id]).unwrap().clone();
// Its first tick is at the kit, not in the air somewhere.
assert!(horizontal(d.pos - site.pos).length() < 25.0, "{:?} first seen {:.0} from its site", d.kind, horizontal(d.pos - site.pos).length());
assert!(d.pos.y - site.pos.y < 12.0, "{:?} first seen {:.1} above the ground", d.kind, d.pos.y - site.pos.y);
if d.kind.spec().ballistic.is_none() {
assert!(d.vel.y > 0.0, "{:?} leaves level, not climbing", d.kind);
}
// One launch at a time from a site.
if let Some(t) = last_launch.insert(site.id, sim.tick) {
assert!(sim.tick - t >= site.class.cadence(), "two launches {} ticks apart", sim.tick - t);
}
seen.insert(d.id, d.pos.y);
}
}
assert_eq!(seen.len() + early, 14, "not every drone left a site");
assert_eq!(sim.sites.list.iter().map(|s| s.stock).sum::<u32>(), 0, "stock left after the raid");
// The climb is real: an early drone is higher a second later, and clear of the ground.
assert!(sim.queue.is_empty());
}
#[test]
fn a_site_launches_only_what_it_holds() {
let mut sim = Sim::new(2);
let mut ground = Ground::default();
let mut out = Out::default();
let id = sim.sites.add(LaunchSite {
id: 0,
side: 0,
sector: 0,
bearing: 0.0,
class: SiteClass::Ramp,
pos: DVec3::new(0.0, 64.0, 300.0),
range: 300.0,
water: false,
stock: 3,
planned: 5,
alive: true,
next_free: 0,
});
for i in 0..5 {
let site = sim.sites.get(id).unwrap().clone();
let d = sim.make_drone(DroneKind::Shahed, site.muzzle(i), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
sim.sites.job(d.id, id);
sim.queue.push((1, d));
}
let mut times = Vec::new();
for _ in 0..80 {
let before = sim.launched;
sim.step(&mut ground, None, &mut out);
if sim.launched > before {
times.push(sim.tick);
}
}
assert_eq!(sim.launched, 3, "launched {:?}", times);
assert!(sim.queue.is_empty(), "the unlaunchable jobs stay in the queue");
assert_eq!(sim.sites.get(id).unwrap().stock, 0);
assert!(times.windows(2).all(|w| w[1] - w[0] >= SiteClass::Ramp.cadence()), "{times:?}");
}
#[test]
fn a_destroyed_site_cancels_its_future_jobs() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(5);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
let ramp = sim.sites.list.iter().find(|s| s.class == SiteClass::Ramp).unwrap().id;
let early = sim.launched as usize;
let n = sim.sites.pending(ramp);
assert!(n >= 8 && n <= 9, "{n}");
assert_eq!(sim.destroy_site(ramp) as usize, n);
assert_eq!(sim.queue.len() + early + n, 14);
for _ in 0..3000 {
step(&mut sim, &mut ground, &land, &mut out);
}
assert_eq!(sim.launched as usize + n, 14);
}
#[test]
fn no_suitable_ground_means_a_clear_refusal() {
// Never answered: unknown ground is not suitable, and nothing is launched.
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(3);
sim.order_raid(&order(Approach::TwoSectors, 300.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
let mut n = 0;
while sim.planning() {
sim.step(&mut ground, None, &mut out);
n += 1;
assert!(n <= SEARCH_TICKS + 5, "a raid waits for ever");
}
assert!(sim.queue.is_empty() && sim.sites.list.is_empty());
assert!(out.messages.iter().any(|m| m.contains("площад")), "{:?}", out.messages);
assert!(!sim.drones.iter().any(|d| d.alive()));
// A cliff face everywhere (too steep to stand on) is refused as well, and a sea (no land) too.
for steep in [true, false] {
let land = Land(move |x, _| if steep { x * 4 } else { -100 });
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(3);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
assert!(sim.queue.is_empty() && sim.sites.list.is_empty(), "steep {steep}");
assert!(out.messages.iter().any(|m| m.contains("площад")));
}
}
/// Land inside a radius of the origin (surface 64), open water outside it (surface 63 over a floor at 50):
/// the game's height over water is the surface, and the blocks under it are not solid.
struct Island(f64);
impl Island {
fn land(&self, x: i32, z: i32) -> bool {
f64::from(x).hypot(f64::from(z)) < self.0
}
}
impl world::WorldSource for Island {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < if self.land(x, z) { 64 } else { 50 })
}
}
fn run_island(radius: f64, seed: u64, o: &RaidOrder) -> (Sim, Out) {
let (mut ground, mut out, island) = (Ground::default(), Out::default(), Island(radius));
let mut sim = Sim::new(seed);
sim.order_raid(o, &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
for _ in 0..SEARCH_TICKS + 5 {
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, if island.land(x, z) { 64 } else { 63 })).collect();
ground.answer(cols, sim.tick);
let secs = ground.world.take_wanted(sim.tick);
ground.world.serve(&island, secs, sim.tick);
sim.step(&mut ground, None, &mut out);
}
for _ in 0..3000 {
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, if island.land(x, z) { 64 } else { 63 })).collect();
ground.answer(cols, sim.tick);
let secs = ground.world.take_wanted(sim.tick);
ground.world.serve(&island, secs, sim.tick);
sim.step(&mut ground, None, &mut out);
}
(sim, out)
}
#[test]
fn an_all_water_map_gives_barges_and_every_drone_launches() {
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Kh101, 3)], approach: Approach::TwoSectors, dist: 200.0, aim: Aim::Objects };
// Water from the first block: nothing but the sea round the raid (its centre is the player's slab, not read).
let (sim, out) = run_island(0.0, 6, &o);
assert!(!sim.sites.list.is_empty(), "no barge: {:?}", out.messages);
assert!(sim.sites.list.iter().all(|s| s.water && s.pos.y > 63.0 && s.pos.y < 64.0), "a site that is not on the surface");
assert_eq!(sim.launched, 9, "{:?}", out.messages);
let mut o2 = Out::default();
sim.draw_sites(&mut o2);
assert!(o2.models.iter().any(|m| m.name == "airdef:barge"));
}
#[test]
fn land_is_preferred_over_water_when_both_are_known() {
// An island of radius 260 in the sea; the raid is ordered 300 out: its sites stand on the island, nearer.
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Kh101, 3)], approach: Approach::TwoSectors, dist: 300.0, aim: Aim::Objects };
let (sim, out) = run_island(260.0, 6, &o);
assert!(!sim.sites.list.is_empty());
assert!(sim.sites.list.iter().all(|s| !s.water), "a barge where land was known: {:?} {:?}", out.messages, sim.sites.list.iter().map(|s| (s.class, s.water, s.pos, s.range)).collect::<Vec<_>>());
assert_eq!(sim.launched, 9);
}
#[test]
fn a_site_never_stands_in_a_hill_or_under_a_roof() {
// A hill 40 blocks high round the raid's ring at 300 out (radius 100): the sites find flat land elsewhere.
let hill = |x: i32, z: i32| {
let (dx, dz) = (f64::from(x) - 0.0, f64::from(z) - 300.0);
if dx * dx + dz * dz < 100.0 * 100.0 { 64 + 40 } else { 64 }
};
let land = Land(hill);
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(8);
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 5)], approach: Approach::Sector, dist: 300.0, aim: Aim::Objects };
plan(&mut sim, &mut ground, &land, &o, &mut out);
for s in &sim.sites.list {
for (dx, dz) in FOOTPRINT {
let h = hill((s.pos.x + dx).floor() as i32, (s.pos.z + dz).floor() as i32);
assert!((f64::from(h) - s.pos.y).abs() <= f64::from(FLAT), "a site over a slope");
}
}
assert!(!sim.sites.list.is_empty());
}
#[test]
fn repeated_raids_do_not_pile_up_sites_and_clear_removes_them() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(12);
plan(&mut sim, &mut ground, &land, &order(Approach::AllSides, 300.0), &mut out);
let first: Vec<u32> = sim.sites.list.iter().map(|s| s.id).collect();
for _ in 0..3000 {
step(&mut sim, &mut ground, &land, &mut out);
}
plan(&mut sim, &mut ground, &land, &order(Approach::TwoSectors, 300.0), &mut out);
assert!(sim.sites.list.iter().all(|s| !first.contains(&s.id)), "old sites stayed");
assert!(sim.sites.list.len() <= 6);
sim.clear();
assert!(sim.sites.list.is_empty() && sim.queue.is_empty());
let mut o = Out::default();
sim.draw_sites(&mut o);
assert!(o.models.is_empty());
}
#[test]
fn the_same_seed_picks_the_same_sites_and_graphics_do_not_move_them() {
let run = |draw: bool| {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(33);
plan(&mut sim, &mut ground, &land, &order(Approach::AllSides, 300.0), &mut out);
if draw {
// Rendering must not spend the combat randomness.
let mut o = Out::default();
sim.draw_sites(&mut o);
assert!(!o.models.is_empty());
}
(sim.sites.list.iter().map(|s| (s.id, s.pos.x.to_bits(), s.pos.z.to_bits(), s.stock)).collect::<Vec<_>>(), sim.queue.iter().map(|q| q.0).collect::<Vec<_>>(), sim.rng.state())
};
assert_eq!(run(false), run(true));
}
}
+40
View File
@@ -0,0 +1,40 @@
//! What the simulation tells the engine sources (`crate::engines`): which sound an entity's
//! engine makes, from the specs the rest of the simulation owns.
use super::{DroneKind, MissileKind};
impl DroneKind {
/// The sound of its engine and its pitch; none for the ballistic ones, which fall with a
/// scream played once (and the Oreshnik's blocks and submunitions, which have no engine).
pub fn engine_voice(self) -> Option<(&'static str, f32)> {
let spec = self.spec();
if spec.ballistic.is_some() || matches!(self, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) {
return None;
}
Some((spec.sound.0, spec.sound.1))
}
}
impl MissileKind {
/// The sound of its motor, its gain and its pitch: a rocket's roar, or the electric whine of
/// a motor without a flame.
pub fn engine_voice(self) -> (&'static str, f32, f32) {
if self.spec().flame == 0.0 {
return ("airdef:drone.lancet", 0.8, 1.6);
}
let pitch = match self {
MissileKind::Pac3 => 1.0,
MissileKind::M57e6 => 1.12,
MissileKind::M48n6 => 0.88,
MissileKind::M77n6 => 0.8,
MissileKind::IrisT | MissileKind::Amraam => 1.05,
MissileKind::M9m317 => 0.9,
MissileKind::M9m338 => 1.15,
MissileKind::P1sun => 1.7,
};
("airdef:missile.flight", 1.0, pitch)
}
/// Ticks from the launch until the motor lights (a cold launch throws it out of the tube first).
pub fn lit_after(self) -> u32 {
self.spec().cold
}
}
+334
View File
@@ -0,0 +1,334 @@
# Акустика AirDef: разбор Sound Physics Remastered и план переноса (#96, этап 1)
Это результат первого этапа: чтение исходников и план. Кода движка и мода этот этап не меняет. Если код
разойдётся с текстом, прав код.
- Изучено: https://github.com/henkelmax/sound-physics-remastered, ветка `master`, коммит
`453654ece086f40c97712d9016baa4dee7c4f6d2` (2026-10-29), лицензия GPL-3.0 (`LICENSE`). Клон лежит только в
scratchpad, ничего из него не собиралось и не запускалось.
- Пути ниже даны от `common/src/main/java/com/sonicether/soundphysics/`. Весь расчёт — это `SoundPhysics.java`
(741 строка) плюс таблицы в `config/`.
- Коммит из текста #96 (`76f680eb…`) в последних 200 коммитах `master` не нашёлся. Выводы привязаны к
`453654ec`.
## 1. Как SPR идёт от источника к результату
Сигнал: Minecraft вызывает `Channel.play` → `mixin/SourceMixin.play` → `SoundPhysics.onPlaySound` →
`evaluateEnvironment` (SoundPhysics.java:240). Это один синхронный расчёт на каждый новый звук, в потоке звукового
движка. Движущиеся звуки пересчитываются только при `update_moving_sounds=true` (по умолчанию выключено), раз в
`sound_update_interval=5` тиков, со сдвигом по `sound.hashCode()` (`mixin/SoundSystemMixin.tickNonPaused`). Относительные
звуки (интерфейс) и дальше `max_sound_processing_distance=512` пропускаются (строки 248-259), как и звуки, превысившие
лимит за тик (`utils/SoundRateManager`: по умолчанию без лимита, 0 только у дождя, грома и гудка козы).
### 1.1 Прямой путь: окклюзия и поглощение (`calculateOcclusion`, `runOcclusion`, строки 535-613)
- `runOcclusion`: луч от источника к камере, до `max_occlusion_rays=16` последовательных попаданий. Каждое попадание
добавляет `occlusion` блока (`config/OcclusionConfig`), затем луч продолжается от точки попадания (так учитывается
толщина стены). Остановка при сумме `> max_occlusion=64`.
- Не полный блок (`!isFaceSturdy` грани, в которую попали): вклад `* 0.25` (`non_full_block_occlusion_factor`).
- Если не `strict_occlusion`: берётся минимум по 9 путям: прямой и 8 со сдвигом обоих концов на `(±v,±v,±v)`,
`v = occlusion_variation = 0.35`; у блочных звуков `v >= 0.49`. Минимум даёт «звук проходит в щель, у двери,
у угла»; это недорогая замена дифракции.
- Итог (строки 287, 302-303):
`cutoff = exp(-occ * 3 * block_absorption)`, `gain = scale * cutoff^0.1`.
Один обычный блок (occ 1): cutoff 0.050 (в OpenAL это `GAINHF`, усиление на 5 кГц), gain 0.74. Два блока:
0.0025 и 0.55. Три: 0.0001 и 0.41. Громкость падает мало, главное — срез верхов; это нужное нам «глухо за горой».
- Значения блоков (`OcclusionConfig.addDefaults`): по умолчанию 1.0; шерсть 1.5, мох 0.75, мёд 0.5, стекло, снег,
бамбук, мокрая трава 0.1; лианы, кусты, посевы, лестницы, цепи, листья-подобное 0; вода 0.25, лава 0.75.
- Фильтр воды и «подводные» множители (строки 319-321, 497-502) нам не нужны.
### 1.2 Выбор и трассировка лучей отражения (строки 323-432)
- `environment_evaluation_ray_count=32` направлений вокруг источника по спирали Фибоначчи на сфере:
`longitude = i * phi * 2pi`, `latitude = asin(2i/N - 1)`; длина луча 256.
- Попавший луч (`RaycastUtils.rayCast`, обход блоков с реальными формами коллизии и жидкостей) отражается
`reflect(d, n) = d - 2(d.n)n` до `environment_evaluation_ray_bounces=4` раз. Если очередной отражённый луч
ушёл в пустоту (`MISS`), цикл отскоков прерывается.
- Для каждого отскока: `energyTowardsPlayer = 0.25 * (reflectivity * 0.75 + 0.25)` (reflectivity последнего
блока, `ReflectivityConfig`: по умолчанию 0.5; камень, глубинный сланец, базальт, туф, кость, незер-кирпич 1.5;
незерак 1.1; металл 1.25, медь 1.25; стекло 0.75; дерево 0.4; трава, гравий 0.3; песок 0.2, снег 0.15;
шерсть, мох, мёд 0.1).
- Аккумулируемая длина пути `totalRayDistance` (путь до первого попадания + сумма отрезков). Если луч последнего
отскока ушёл в пустоту, к ней добавляется расстояние от точки отскока до слушателя.
- «Общее воздушное пространство» (`getSharedAirspace`, строки 632-653): из каждой точки отскока и из самого
источника пускается луч к слушателю без пропусков; если он свободен, это отражённый или прямой путь, который
реально доходит. Из таких путей считаются `sharedAirspaces` и направления.
- Перенаправление источника (`ReflectedAudio.evaluateSoundPosition`): новая позиция источника — направление,
взвешенное по `1/(длина пути)^2` по всем свободным путям (плюс прямое, если оно есть), на прежнем расстоянии от
слушателя. Так звук из-за горы «приходит» из проёма, а не сквозь гору. Не применяется, если путь виден напрямую
(`redirect_non_occluded_sounds`).
### 1.3 Затухание, задержки, оценка окружения (строки 409-492)
Важное, чего в README нет и что менее очевидно, чем кажется по описанию #96:
- **Задержек и дискретных эхо у SPR нет.** `reflectionDelay = totalRayDistance * 0.12 * blockReflectivity` — не время,
а индекс, который перекрёстным смешиванием (`cross0..cross3`, «шляпы» шириной 1) раскладывает вклад луча по
четырём фиксированным реверб-слотам. Слот 0 берёт delay 0..1, слот 1 — 1, слот 2 — 2, слот 3 — больше 2.
Для камня (1.5) слот 3 начинается примерно с пути 11 блоков, для травы (0.3) с 55. В ущелье почти всё
уходит в слот 3: один длинный хвост, а не отдельное «бабах... бабах» от стенки.
- Вклад в слот: `send_i += cross_i * energy * {6.4, 12.8, 12.8, 12.8} / (rays * bounces)`; затем слот 1
умножается на долю отскоков 1-го порядка с отражающим блоком (`bounceReflectivityRatio[1]`), слот 2 на `ratio[2]^3`,
слот 3 на `ratio[3]^4` (чем меньше лучей доживает до глубокого отскока, тем тише длинный хвост); слоты 2 и 3
`*1.05 - 0.05`; всё в 0..1.
- Фильтр посылки (`sendCutoff_i`): доля общего воздушного пространства
`shared = sharedAirspaces * 64 / (rays * bounces)` даёт вес `clamp(shared / {20,15,10,10})`;
`sendCutoff = exp(-occ*3*absorb) * (1-w) + w`. То есть, если до слушателя дотягивается много свободных путей,
реверб идёт без срезания верхов, иначе приглушён как и прямой звук. Сухой звук при этом получает ту же
логику: `directCutoff = max(sqrt(avg(w)) * 0.2, directCutoff)`: через щели всегда проходит что-то приглушённое.
- Затухание реверба по расстоянию добавлено костылём (комментарий в коде, строки 484-488):
`send *= 1 - min(dist / (maxDistance * reverb_distance), 1)`, `reverb_distance=1.5`.
Сам звук затухает штатно в OpenAL (линейно по расстоянию до `AL_MAX_DISTANCE`; у нас это `distance_gain`).
- Воздушное поглощение — штатное `AL_AIR_ABSORPTION_FACTOR = 1` (OpenAL Soft сам считает срез верхов от
расстояния; по документации EFX это 0.994 усиления на 5 кГц на единицу длины, то есть 0.55 на 100, 0.16 на 300
— проверить по исходникам OpenAL Soft, их здесь не читали).
### 1.4 Реверб и EFX (`setupEFX`, `setReverbParams`, `config/ReverbParams`)
Четыре auxiliary effect slot'а (`ALC_MAX_AUXILIARY_SENDS=4` через `mixin/LibraryMixin`), в каждом `EAXREVERB`
с неизменными (не зависящими от места!) параметрами. Окружение выражено только уровнями посылок источника в эти
слоты:
| слот | decay, с | density | gain | gainHF | decayHFratio | reflections gain / delay, с | late gain / delay, с | rolloff |
|---|---|---|---|---|---|---|---|---|
| 0 | 0.15 | 0.0 | 0.2 | 0.99 | 0.6 | 2.5 / 0.001 | 1.26 / 0.011 | 0.16 |
| 1 | 0.55 | 0.0 | 0.3 | 0.99 | 0.7 | 0.2 / 0.015 | 1.26 / 0.011 | 0.15 |
| 2 | 1.68 | 0.1 | 0.5 | 0.99 | 0.7 | 0.0 / 0.021 | 1.26 / 0.021 | 0.13 |
| 3 | 4.142 | 0.5 | 0.4 | 0.89 | 0.7 | 0.0 / 0.025 | 1.26 / 0.021 | 0.11 |
`gain` умножается на `0.7 * reverb_gain * 0.85`; `diffusion = 1` везде. На источник вешаются 4 lowpass-фильтра
посылок (`AL_AUXILIARY_SEND_FILTER`), 1 прямой lowpass (`AL_DIRECT_FILTER`): `GAIN` и `GAINHF`
(`setEnvironment`, строки 663-704). Всё остальное — собственный DSP OpenAL Soft (LGPL, и его мы не портируем).
### 1.5 Что SPR вычисляет сам и что отдаёт бэкенду
| Сам SPR | Отдаёт OpenAL |
|---|---|
| лучи, окклюзия, отскоки, пути, доли, `exp(-occ*3)`, перенаправление позиции | реальный lowpass (фильтр LP/HF-shelf на 5 кГц), посылки, EAXReverb, воздушное поглощение, затухание по расстоянию, панорама |
### 1.6 Мир и потоки, кеш
- `world/ClonedClientLevel`: копия секций чанков в радиусе `level_clone_range=4` чанка (петля `-4..3`, то есть
8x8 чанков, примерно 64 блока вокруг игрока), обновляется раз в 20 тиков или после сдвига на 16 блоков
(`utils/LevelAccessUtils.tickLevelCache`). Лучи читают только эту копию, поэтому звук можно считать вне главного
потока. Блок вне копии — `VOID_AIR`. **Следствие: SPR не видит гор дальше ~64 блоков.** Дальний взрыв за
горой для него — открытое поле.
- Кеша результатов нет. Каждый новый звук — свои ~350 лучей (прямой 1..9 путей по до 16 пересечений, 32 луча x до 5
отрезков, ещё ~160 лучей воздушного пространства). `TaskProfiler` только измеряет. Платит SPR временем потока
звука на каждый `play`, поэтому и существует лимит `SoundRateManager`.
- Приоритетов, бюджета по времени, затухающей детализации по расстоянию нет, только жёсткие пороги.
## 2. Что зависит от OpenAL, и чем это заменить в нашем микшере
`audio.rs` сегодня (`mix`, ~строки 402-501): на голос — линейная интерполяция, `distance_gain`, панорама
`stereo_gains`; потом общий лимитер. Нет ни фильтров, ни посылок. Вызов `mix` общий для живого потока cpal и
`film_mix` (48 кГц, на часах съёмки), поэтому любой DSP, живущий в `Mixer`, автоматически одинаков вживую,
в реплее и в оффлайн-MP4.
Нужный минимум (всё своё, без кода OpenAL Soft; фильтры и FDN — стандартная техника):
1. **Low-pass на голос.** Однополюсный `y += a*(x-y)`; отсечка из «GAINHF на 5 кГц» SPR:
`fc = 5000 / sqrt(1/g^2 - 1)` (для `g` в 0.001..0.999), `a = 1 - exp(-2 pi fc / rate)`. Плюс gain.
Однополюсник — упрощение OpenAL-овского HF-shelf (там НЧ не режется, у нас и так почти не режется), для
«глухо» достаточно; при сомнениях заменить на два каскада.
2. **Сглаживание параметров** (slew 50-100 мс на gain и `a`), иначе щелчки при смене окклюзии.
3. **Ранние эхо: 2-6 отводов на голос**, читающих тот же `Arc<Vec<f32>>` источника на позиции `position - delay*step`.
Памяти на линии задержки не нужно (звуки у нас целиком декодированы, `Channel.samples`); для стримов (музыка)
отводы выключены. Параметры отвода: задержка (сэмплы), gain, `a` фильтра. Голос после конца сэмплов остаётся
жить в состоянии «хвост» ещё `max_delay`, иначе эхо оборвётся вместе с хлопком.
4. **Посылка в реверб.** На голос уровень посылки `send` (1-2 шины), накапливается моно в буфер шины перед
панорамой. Шина: вход → pre-delay → 4 all-pass (диффузия) → FDN из 8 линий (взаимно простые длины 7..80 мс,
матрица Адамара 8x8, потери на линию `g = 10^(-3 L / (RT60 * rate))`, lowpass в петле для `decayHFratio`) → стерео-
декорреляция (разные отводы для L и R). Две шины с разным RT60 (комната/тоннель ~0.4-1.2 с и открытая местность/
ущелье 2-4 с), между ними перекрёстное смешивание по классу окружения (как слоты SPR). Параметры из §1.4
(0.15/0.55/1.68/4.14 с, gainHF) годятся стартовыми значениями RT60.
5. **Состояние сброса:** шины и хвосты обнуляются в `film_begin/film_end`, при смене мира, `stop_all`.
Оценка стоимости (расчёт по числу операций, не замер; замер — условие включения, см. этап 1):
| Часть | Операций на сэмпл | 100 голосов, 48 кГц |
|---|---|---|
| сегодня: интерполяция + панорама | ~12 | 58 Mflop/с |
| + LP + slew | ~6 | +29 |
| + 4 отвода (чтение, интерполяция, LP, gain) | ~32 | +150 |
| + посылка | ~2 | +10 |
| 2 шины FDN8 на всё | ~70 на шину | ~7 всего |
Итого около 250 Mflop/с на 100 одновременно звучащих голосов, то есть порядка 0.5-1 мс на буфер 512 кадров
(10.7 мс) при скалярном коде — единицы процента одного ядра M-серии. Ограничения: отводы только у `N_full=16` самых
слышимых голосов (остальные — LP и посылка), и всё равно сначала бенч `mix` в release с 100 голосами. Память:
шины ~2x160 КБ, на голос ~100 байт состояния; задержки у голосов нет. Callback не аллоцирует: буферы шин и
состояние в `Channel` создаются заранее, параметры пишет игровой поток под тем же `Mutex<Mixer>`, что уже есть
(держать критическую секцию короткой; если встретятся пропуски — перейти на тройной буфер/атомики).
## 3. Лучи: что есть и как сопоставляется
Что есть:
- Мод: `render.query-rays` (до 256 лучей/тик, ответ со следующего тика, до 512 блоков, политика `sound` =
только полные кубы), `query-sections` (64/тик, битовые сетки 16^3), `world-changes` (revision, секции) — в `horizon.wit`,
реализация `game/mod_world.rs` (`Probe` над `World.chunks`, `CELLS_PER_TICK = 200_000`) и чистый обход
`horizon_mods::world_query::trace` (обобщён над `BlockProbe`).
- Клиент: `World.chunks` — `Arc<Chunk>` с копированием при записи (`Arc::make_mut`), поэтому рабочий поток может держать
снимок чанков без блокировок и увидеть стабильную, пусть и устаревшую картину.
Что не хватает: политика `sound` бинарна и без материала; ответ на луч не говорит, что за блок попали (только
координаты), а звуку нужны вклад окклюзии и отражательная способность. Поэтому **акустика живёт в клиенте** (это же
требование #96: общий DSP для live/реплея/MP4), а мод только сообщает события и движущиеся источники через #95.
Мод не должен сам считать лучи для звука и дублировать эхо.
Решение:
- Новый модуль `horizon-client/src/acoustics/`: `world.rs` (свой `AcousticProbe`: `Cell::Solid` с таблицей по
состоянию блока `occ: u8` и `refl: u8`, строится из `StateFacts.sounds.step`/`classes`/`full_collision`, один раз на
эпоху мира, как `WorldTables`); `trace.rs` (тот же обход `world_query::trace` — проверить, что модуль не за
feature `mods`; иначе вынести в `horizon-core`/`horizon-world`).
- Ускорение дальних лучей: пропуск пустых секций 16^3 (DDA по секциям поверх `section_bits`), иначе 512-блочный луч
это ~1500 клеток, и бюджет 200k даёт только ~130 лучей. Со секциями луч — десятки шагов. Секции кешируются
(`empty`/`full` — 1 байт, `mixed` — 512 байт; LRU, потолок 16 МБ).
- Неизвестное (`Unknown`, чанка нет) трактуется как открытый воздух, а не стена; доля Unknown идёт в телеметрию.
Исходный SPR делает то же (`VOID_AIR`), но у него «неизвестно» начинается за ~64 блоками, у нас за 512 и дальше.
- В callback мира нет: callback видит только числа (`gain, lp_a, send, taps[]`), которые положил планировщик.
### Планировщик (для ~100 источников, взрыв издалека, большой налёт на Mac)
1. **Разделение работы.** Источник = голос с `AcousticState {dry_gain, dry_a, send, taps, virtual_pos, stamp}`.
Есть три класса:
- `Full` (до 16 самых слышимых, плюс все взрывы мощностью >= 2 вблизи 400 блоков): прямой путь (до 3 путей из 9
SPR), 16 лучей среды x 2 отскока, до 4 эхо-лучей;
- `Direct` (остальные до 100): 1 прямой путь, 1 пересечение стен;
- `Free` (дальше 512 или тише порога): без расчёта, только воздушное поглощение по расстоянию.
Слышимость: `loud = volume * distance_gain` до окклюзии. Cinema.rs уже режет налёт по `FULL=3` на тик — тот же
`rank` использовать для класса.
2. **Очередь с бюджетом.** Бюджет ~1 мс времени расчёта на игровой тик (замеряется `Instant`) и потолок клеток;
в очереди работы приоритет `loud * (1 + возраст)`; недоделанное остаётся со старыми значениями. Взрыв
ставится вперёд очереди (он звучит один раз). Телеметрия в `census()` (`status`): лучей, клеток, мс, длина
очереди, попаданий в кеш, отброшенных.
3. **Рабочий поток** (стадии 2-3; стадию 1 можно на игровом потоке под бюджетом, как `mod_world`). Он получает снимок
`Arc<Chunk>` в радиусе ~512 блоков и список изменений мира; отдаёт `AcousticState` через канал; игровой поток
применяет их в начале тика.
4. **Кеш.** Ключ: (клетка источника 4x4x4, клетка слушателя 4x4x4, класс); значение: параметры и `AABB` лучей.
Инвалидация: `world-changes` (revision и секции) — запись удаляется, если её AABB задевает изменённую секцию;
`epoch` — всё. Разрушенная преграда (в полигоне) так обновляется за один тик очереди.
Для окружения слушателя («зонд»: 24 луча вокруг камеры, раз в 250 мс или при сдвиге на 4 блока) отдельная запись,
общая для всех источников: из неё берётся класс реверба и хвост (комната, тоннель, поле, ущелье); этим
«много источников используют общие шины и зонды».
5. **Движущиеся источники** (дрон, ракета): интервал пересчёта `clamp(distance / 200 с, 0.1, 1.0)` с фазовым сдвигом от
id голоса (как `hashCode % interval` в SPR); значения сглаживаются в микшере; решение об окклюзии с гистерезисом.
Ракета близко пролетает мимо слушателя — частоту повышаем по сближению, а не по времени.
6. **Оффлайн и реплей.** При съёмке расчёт синхронный и бюджет по счётчику (лучи/клетки на тик), не по часам, иначе
результат зависит от скорости GPU и пишется недетерминированно. Слушатель — камера реплея (та же `set_listener`).
Геометрия — текущий мир; если реплей показывает уже разрушенное, акустика покажет сегодняшнюю карту (проверить, что
делает реплей с блоками).
7. **Двойное эхо с cinema.rs.** `_far`-варианты (приглушённый звук, поздний гул `blast.big_far` с `delay 10+2*power`)
и `travels()` с задержкой по `SOUND_SPEED` — это моделирование «издалека» заранее запечёнными сэмплами. С включённой
акустикой они дублируют срез верхов и хвост. При `acoustics=on` мод должен играть близкие варианты (без `_far`) и без
«позднего гула»: срез от расстояния, эхо и хвост делает движок. Задержку распространения оставить в моде до #95, потом
перенести в движок (по длине пути, с точностью до сэмпла), иначе она дважды считается и привязана к тику (50 мс).
## 4. Какие материалы и группы звуков важны
Берём `StateFacts.sounds.step` (например `minecraft:block.stone.step`), из него группа; для нее два числа
из SPR (`occ`, `refl`). Большинство блоков остаётся на значениях по умолчанию (`occ` 1.0, `refl` 0.5).
| Группа | occ | refl | Зачем |
|---|---|---|---|
| stone, deepslate, tuff, basalt, calcite, netherrack, nether_bricks, bone | 1.0 | 1.5 (1.1 незерак) | горы, здания, бетон, кирпич: основные отражатели эха |
| metal, copper, anvil, netherite | 1.0 | 1.25 | ангары, пусковые |
| wood | 1.0 | 0.4 | крыши, дома |
| gravel, grass, dirt | 1.0 | 0.3 | холмы глушат, но слабо переотражают |
| sand | 1.0 | 0.2 | |
| snow | 0.1 | 0.15 | снег не глушит |
| wool | 1.5 | 0.1 | мешки с песком, окна: глушит, не отражает |
| glass | 0.1 | 0.75 | окна пропускают звук, отражают |
| water | 0.25 | 0.5 | море, озёра: гладкая плоскость |
| листья, трава-растения, лианы, лестницы, цепи, посевы | 0 | 0.1 | нет окклюзии |
| неполный блок (`!full_collision`) | `occ * 0.25` | как у группы | двери, ступеньки, заборы |
Пропускаем: подводный фильтр, мёд, аметист, фонари и прочее по `SoundType` (всего ~45 групп у SPR; нам хватает
~12), звуки ambient и музыка, записи, интерфейс (`relative`), голосовой чат, расширение дальности `getRange x4`
(у нас свои дальности в моде). Ключевое для AirDef: стоит ценность в **дальности лучей** (512, не 256) и
в дешёвом обходе, а не в количестве материалов.
Целевые ощущения и как их получить:
- Ущелье: лучи среды находят стены на 20-150 блоков, из них 4 самых длинных (разнесённых по направлению) становятся
эхо-отводами с задержкой `path/343 с` (150 блоков = 0.44 с, 400 = 1.2 с) и gain `0.25*(0.75 refl + 0.25) / (1 + path/50)`;
плюс шина с RT60 2-4 с. **Дискретное эхо SPR не даёт, это наше добавление** (§1.3).
- За горой: окклюзия `exp(-3 * occ_sum)` по нескольким блокам горы (до 64) даёт глухой остаток, а перенаправление позиции
(§1.2) сдвигает источник в проём. Длинные лучи нужны, чтобы вообще её увидеть.
- Под крышей, тоннель: зонд слушателя видит близкие стены во всех направлениях (средняя длина < 12 блоков), RT60 шины
0.4-1.2 с, сильная посылка, малая задержка раннего. В открытом поле: лучи уходят в небо (MISS), хвоста нет.
- Воздушный взрыв: короткий и сильный; эхо считаем один раз на событие и общим для его слоёв, а не на каждый слой.
## 5. Стадии
Общее правило: включается настройкой форка/мода `acoustics` (`off|direct|reflect|full`), по умолчанию `off` — звук
как сегодня, бит в бит. Каждая стадия за своим флагом `HORIZON_ACOUSTICS_*` для отладки. Сводка логики в телеметрии
`status`.
Лицензия: GPL-3.0 код переносить только в отдельные файлы. Планируется: `horizon-client/src/acoustics/spr_*.rs` (порт
формул и таблиц) с шапкой «GPL-3.0-only, портировано из Sound Physics Remastered @453654ec, файл/функция»; рядом
`acoustics/THIRD_PARTY_SPR.md` в стиле `gfx/THIRD_PARTY_PHOTON.md` (коммит, список взятого, полный текст GPL-3.0,
что осталось нашим). Весь остальной код (микшер, планировщик, DSP) пишется заново и остаётся MIT. Предупреждение
владельцу: бинарник форка, куда слинкован GPL-код, при распространении должен соответствовать GPL-3.0 (исходники
целиком); «отдельные файлы» делают код опознаваемым, но не освобождают от этого. Смягчение: порт под cargo feature
`spr-gpl`, без неё остаётся наш собственный `direct` на простой модели. Решение за владельцем. В SPR переносимы
по сути только формулы (§1) и таблицы (§4); DSP у SPR нет вообще (он в OpenAL).
### Стадия 1. Прямой звук (окклюзия)
- Файлы: `audio.rs` (LP на голос, slew, поля `Channel`), `acoustics/{mod,world,trace,spr_occlusion}.rs`,
`sound_engine.rs` (где голос получает позицию: вызов планировщика), `game/film.rs` (синхронный режим).
- Порт: `runOcclusion`, `calculateOcclusion` (9 путей), `exp(-occ*3)`, `^0.1`, таблицы §4, перенаправление позиции.
- Тесты: чистые, без игры: (а) фикстура `BlockProbe` как в `world_query::tests`: стена из 1, 2, 3 блоков → cutoff
0.050 / 0.0025 / 0.0001, gain 0.74 / 0.55 / 0.41; щель у двери проходит; неизвестный чанк не глушит;
(б) `mix` с синусом 8 кГц: энергия на выходе падает в нужное число раз, для 500 Гц почти не меняется; (в) slew
без скачков (макс. разность соседних сэмплов); (г) выключатель: `off` даёт тот же вывод, что до изменения (хеш).
- Проверка без слуха: `film_mix` на сцене «источник за стеной» → `.audio.raw` (s16le 48 кГц) → скрипт
`tools/acoustics_check.py` (stdlib): энергия по полосам (до 1 кГц и выше 4 кГц), отношение «за стеной/на открытом»
против ожидаемого; сравнение `off` и `direct` на одном сэмпле и точке.
- Критерий: падение верхов на 20-30 дБ за 1 блоком камня, на открытом месте разницы нет; бюджет в `status`.
### Стадия 2. Ранние отражения и эхо
- Файлы: `audio.rs` (отводы, хвост голоса), `acoustics/{scheduler,echo,cache}.rs`, рабочий поток, `spr_bounce.rs`
(спираль Фибоначчи, `reflect`, `energyTowardsPlayer`, общее воздушное пространство).
- Тесты: детерминированная фикстура «ущелье» (две стены, расстояние до слушателя известно): отводы появляются с задержкой
`path/343` ± 1 сэмпл; кеш: повтор запроса — попадание; разрушенная стена (изменение секции) инвалидирует запись;
бюджет: 100 запросов за тик не превышают потолок клеток, очередь убывает; эхо живёт после конца сэмпла (голос в
состоянии «хвост» ровно `max_delay`); `off` бит-в-бит.
- Проверка без слуха: импульсный сэмпл (щелчок) в сцене «ущелье» и «поле»; в записи ищем пики автокорреляции/огибающей:
в ущелье пики на расчётных задержках с точностью до 1 мс, в поле нет. Оффлайн-MP4 дважды с разным GPU-таймингом
(`HORIZON_*` число кадров на тик) → побайтно одинаковая дорожка.
- Критерий: измеренные задержки совпадают с геометрией, замер CPU `mix` на 100 голосах и время планировщика на налёте
(по `status` и `hd_bench`-подобному прогону).
### Стадия 3. Поздний реверб
- Файлы: `audio.rs` (шины FDN, посылки, сброс), `acoustics/probe.rs` (зонд слушателя, класс окружения),
`spr_reverb.rs` (формулы посылок §1.3, начальные параметры §1.4).
- Тесты: FDN устойчив (энергия монотонно убывает при любых параметрах класса, нет NaN/denorm на 10 с тишины после
импульса); RT60 шины совпадает с заданным (Schroeder backward integration, допуск 15%); перекрёстное смешивание
классов не даёт скачка; сброс при `film_begin/film_end`; лимитер не срабатывает на нормальном налёте.
- Проверка без слуха: импульсный отклик шины из `film_mix` → RT60 по спаду (`tools/acoustics_check.py`) для четырёх
классов сцены (поле, ущелье, комната, тоннель) — таблица в отчёте; спад должен упорядочиться
поле < комната < тоннель < ущелье; налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
- Критерий: хвост после конца исходного хлопка длится ожидаемое RT60 и не продолжается после смены мира.
### Стадия 4 (по #96): движущиеся источники и интеграция с #95/cinema
Пересчёт по интервалам (§3.5), `acoustics=on` ⇒ мод играет близкие варианты (§3.7), категории и слушатель реплея.
Зависит от #95 (постоянные источники): ждать его слияния, `audio.rs` сейчас правит другой агент — не начинать
стадию 1 до него, чтобы не конфликтовать по `Channel`/`mix`.
## Что оказалось неверным или неточным в тексте #96
- «Ранние отражения с задержкой и затуханием» в SPR нет: есть только 4 фиксированных реверб-слота OpenAL; индекс слота
вычисляется из длины пути. Эхо от скалы с реальной задержкой — наша собственная разработка (стадия 2).
- DSP и реверб SPR целиком отдаёт OpenAL (LGPL, не портируем). Портируются формулы и таблицы, а не «микшер».
- SPR видит мир только в ~64 блоках вокруг игрока (клон 8x8 чанков) и не кеширует результаты: к «дальним
взрывам за горой» и к «100 источникам» он не готов, это придётся решать самим (длинные лучи, секции, очередь).
- Коммит из #96 (`76f680eb…`) не найден в недавней истории `master`; разбор сделан по `453654ec`.
- Мод-API `query-rays` с политикой `sound` для этого недостаточно (нет материала и полупрозрачных долей), поэтому
акустика в клиенте; мод только сообщает события (#95).
+4
View File
@@ -82,6 +82,10 @@ python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "
## 3. В игре
- Обновление из исходников: `git pull`, затем снова `all` (Windows: `py tools\airdef.py all`). Форк и упаковщик мода
пересоберутся сами, если патчи изменились.
- Windows: нужна видеокарта с Vulkan. Экспорт фильмов (`/airdef replay export`) требует `ffmpeg` в PATH или `HORIZON_FFMPEG`.
- Шейдеры уровня шейдерпаков: `/airdef shader hd`. Для слабой видеокарты перед запуском: `$env:HORIZON_HD_SCALE="0.67"` (PowerShell).
- Создай мир с читами (или включи их через «Открыть для сети»): мод выдаёт пульты и взрывы командами.
- `/airdef give` — пульты, **N** — командный пункт (роли ПВО), **K** — монитор комплекса,
`/airdef wave mix 20` — налёт. Подробнее — README.
@@ -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/35] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/37] 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/35] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/37] 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/35] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/37] 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/35] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/37] 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/35] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/37] 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/35] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/37] 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/35] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/37] 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/35] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/37] 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/35] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/37] 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/35] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/37] 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/35] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/37] 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/35] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/37] 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
@@ -1,7 +1,7 @@
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/35] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/37] 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>
@@ -1,7 +1,7 @@
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/35] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/37] 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>
@@ -1,7 +1,7 @@
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/35] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/37] 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>
@@ -1,7 +1,7 @@
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 16/35] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/37] HD: shadows draw only sun-facing quads and skip buried
sections, the far cascade every second frame; TAA (Halton jitter,
reprojection by depth, YCoCg variance clipping) before bloom
@@ -1,7 +1,7 @@
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 17/35] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/37] Mods: model handles (render.model-id, model-generation,
set-models-by-id; API level 11) so mods send no model names each tick;
clear-post and set-lighting documented
@@ -1,7 +1,7 @@
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:19:57 +0300
Subject: [PATCH 18/35] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/37] HD: temporal upscaling (HORIZON_HD_SCALE: world,
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
pass); fix flickering shadows near the player (the near cascade lost its
sections on frames the far one was held, the held far matrix ignored the
@@ -1,7 +1,7 @@
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:47:52 +0300
Subject: [PATCH 19/35] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/37] HD water: its own pass (colour, normal, depth), the
floor under it lit, a composite with world-space waves, refraction with depth
absorption in the biome colour, screen-space reflections, Fresnel and a
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
@@ -1,7 +1,7 @@
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:56:04 +0300
Subject: [PATCH 20/35] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/37] HD water: no vanilla texture on the surface (the biome
colour only), a darker, deeper colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 18:38:19 +0300
Subject: [PATCH 21/35] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/37] HD sky and light: a physical atmosphere (Rayleigh, Mie,
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
and water reflections; warmer sunlight from the atmosphere's transmittance, a
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
@@ -1,7 +1,7 @@
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 19:02:16 +0300
Subject: [PATCH 22/35] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/37] HD water: a darker blue, a little clearer, ripples a
little higher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 21:41:48 +0300
Subject: [PATCH 23/35] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/37] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
tint, blue hour; MIT), volumetric clouds with ground shadows instead of
vanilla's in HD, volumetric fog with sun shafts and morning mist
(HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0)
@@ -1,7 +1,7 @@
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 21:43:22 +0300
Subject: [PATCH 24/35] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/37] Mods: world queries for simulations (render.query-rays,
query-sections, world-changes; API level 12): block-exact rays and section
occupancy by collision, sight or sound, answered next tick within budgets,
and a log of changed sections
@@ -1,7 +1,7 @@
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 22:56:15 +0300
Subject: [PATCH 25/35] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/37] HD fixes: rain level from the world not from rain
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
without a depth heuristic (#35), air fog once over water (#37), translucent
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
@@ -1,7 +1,7 @@
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:19:53 +0300
Subject: [PATCH 26/35] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/37] HD split into modules (gfx/hd/: one Rust file and one
WGSL file per pass, a shared prelude), contracts for colour, depth, alpha,
time and coordinates written down, tests that the frame layout and bindings
match between Rust and WGSL (#45)
@@ -1,7 +1,7 @@
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:22:19 +0300
Subject: [PATCH 27/35] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/37] HD clouds: noise scales that divide the camera's
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
0
@@ -1,7 +1,7 @@
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:17:11 +0300
Subject: [PATCH 28/35] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/37] HD: volumetric smoke and missile trails with clear
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
speculars by material, wet surfaces and puddles in rain, wind in foliage
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
@@ -1,7 +1,7 @@
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:26:09 +0300
Subject: [PATCH 29/35] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/37] HD clouds composited by depth so terrain and vehicles
inside or behind the layer are hidden and those in front stay in front (#67);
world-anchored noise everywhere (cloud detail by height above the layer, fog,
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
@@ -1,7 +1,7 @@
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:44:45 +0300
Subject: [PATCH 30/35] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/37] HD smoke: young trails drawn at least as wide as a grid
cell (density scaled to keep the mass), gentler bulges, so a trail is a
smooth ribbon and not a chain of puffs
@@ -1,7 +1,7 @@
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:59:41 +0300
Subject: [PATCH 31/35] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/37] Mods: render.set-main-view (API level 15) renders the
screen's world from a mod camera with the full pipeline, so replays get HD
and the post effects; small mod views get the post pass with their own camera
@@ -1,7 +1,7 @@
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 09:28:05 +0300
Subject: [PATCH 32/35] Spot lights for mods (render.set-spot-lights, API level
Subject: [PATCH 32/37] Spot lights for mods (render.set-spot-lights, API level
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
@@ -1,7 +1,7 @@
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 14:30:39 +0300
Subject: [PATCH 33/35] HD clouds and fog polish: cloud panorama, cirrus,
Subject: [PATCH 33/37] HD clouds and fog polish: cloud panorama, cirrus,
terrain-aware fog, far smoke cascade; replay world log and film clock;
material overlays by sprite name
@@ -1,7 +1,7 @@
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:01:09 +0300
Subject: [PATCH 34/35] HD: coloured sun through glass and leaves, rain ripples
Subject: [PATCH 34/37] HD: coloured sun through glass and leaves, rain ripples
on water, film motion blur and depth of field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:19:32 +0300
Subject: [PATCH 35/35] Film export: every frame in flight reaches the writer,
Subject: [PATCH 35/37] Film export: every frame in flight reaches the writer,
listener follows the main view, cloud wind on film frames, set-smoke reports
real activity
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -71,7 +71,8 @@ def fork():
stamp = fork_stamp(commit, patches)
stamp_file = FORK / ".git" / "airdef-stamp"
if not (FORK / ".git").exists():
run(["git", "clone", "--filter=blob:none", UPSTREAM, FORK])
# Без CRLF на Windows: патчи накладываются на файлы как в апстриме.
run(["git", "clone", "-c", "core.autocrlf=false", "--filter=blob:none", UPSTREAM, FORK])
branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip()
if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp:
print("horizon/: ветка airdef на нужных коммите и патчах")
@@ -110,11 +111,16 @@ def built_game() -> Path:
def mod():
tool = shutil.which("cargo-horizon-mod") or Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE)
if not Path(tool).exists():
if not (FORK / "Cargo.toml").exists():
fork()
if not (FORK / "Cargo.toml").exists():
fork()
# Упаковщик должен быть от этого форка: старый не знает новых уровней API.
stamp_file = FORK / ".git" / "airdef-packer-stamp"
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
stamp = fork_stamp(commit, sorted((ROOT / "horizon-patches").glob("*.patch")))
if not Path(tool).exists() or not stamp_file.exists() or stamp_file.read_text().strip() != stamp:
# Without trial runs (wasmtime): building a mod needs only packing, a fraction of the compile.
run([cargo(), "install", "--locked", "--no-default-features", "--path", FORK / "crates" / "cargo-horizon-mod"])
run([cargo(), "install", "--locked", "--force", "--no-default-features", "--path", FORK / "crates" / "cargo-horizon-mod"])
stamp_file.write_text(stamp + "\n")
run([cargo(), "horizon-mod", "build"], cwd=ROOT / "airdef-mod")
+33
View File
@@ -1193,6 +1193,36 @@ def ew_truck():
return p
def launch_ramp():
"""Рампа-катапульта для дронов: основание, наклонные рельсы (нос на север поднят), опоры и ложе."""
p = [el((1, 0, -6), (15, 2, 30), "dark_metal"), el((6, 2, 0), (10, 4, 26), "metal")]
p += both(el((3, 9, -14), (5, 11, 30), "metal", rot=("x", 22.5)))
p += both(el((3, 0, -1), (5, 12, 1), "dark_metal")) + both(el((3, 0, 17), (5, 6, 19), "dark_metal"))
p += [el((4, 10, 24), (12, 14, 30), "dark_metal"), el((1, 2, 28), (15, 6, 32), "ru_khaki")]
return p
def launcher_truck():
"""Пусковая машина ракет: шасси МАЗ и наклонный пакет труб, смотрящий вперёд (на север) вверх."""
p = maz_chassis("ru_khaki")
for x in (4, 10):
p.append(el((x, 15, -4), (x + 5, 20, 30), "tube", {"north": "tube_cap", "south": "tube_cap"}, rot=("x", 45)))
p += [el((3, 13, 22), (13, 17, 28), "dark_metal")]
return p
def barge():
"""Баржа под пусковой комплект: плоская палуба 3×3 блока, корпус, борта, кнехты и скошенный нос."""
p = [el((-16, 0, -16), (32, 3, 32), "dark_metal"),
el((-16, 3, -16), (32, 4, 32), "metal"),
el((-15, 3, -15), (31, 4.4, 31), "ru_khaki")]
p += both(el((-16, 4, -16), (-14, 6, 32), "dark_metal"))
p += [el((-16, 4, 30), (32, 6, 32), "dark_metal"), el((-16, 4, -16), (32, 6, -14), "dark_metal")]
for z in (-10, 8, 26):
p += both(el((-12, 4.4, z), (-9, 6.5, z + 3), "metal"))
return p
MODELS = {
"shahed": (shahed, "Шахед-136"),
"shahed_prop": (shahed_prop, "Винт"),
@@ -1251,6 +1281,9 @@ MODELS = {
"radar_truck": (radar_truck, "Шасси РЛС"),
"depot": (depot, "Склад боеприпасов"),
"tzm_truck": (tzm_truck, "Транспортно-заряжающая машина"),
"barge": (barge, "Баржа"),
"launch_ramp": (launch_ramp, "Рампа запуска дронов"),
"launcher_truck": (launcher_truck, "Пусковая машина ракет"),
"ew_truck": (ew_truck, "Станция РЭБ"),
"searchlight": (searchlight, "Прожектор"),
"searchlight_head": (searchlight_head, "Голова прожектора"),