Compare commits

...

10 Commits

Author SHA1 Message Date
chexit 318a161a3d HD clouds no longer warp while walking (fork patch 38)
build / mod (push) Successful in 3m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:07:38 +03:00
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
chexit 0f5c4136f3 Replays: smoke rebuilt from the recording, recorded objects and searchlights, film cut in true order, export sessions guarded, gun camera above water; export keeps its last frames; hd_bench no longer excuses strong flicker (#76-#84, fork patch 35)
build / mod (push) Successful in 3m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:19:32 +03:00
chexit 51f9609327 Distinct item icons: a silhouette per item and category instead of one remote (#99)
build / mod (push) Successful in 3m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:07:54 +03:00
chexit 3265eb63d2 HD: coloured shadows, rain ripples, film motion blur and depth of field (fork patch 34)
build / mod (push) Successful in 3m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:01:09 +03:00
99 changed files with 13873 additions and 310 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 B

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

After

Width:  |  Height:  |  Size: 338 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 B

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 257 B

+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");
}
}
+197 -82
View File
@@ -243,21 +243,42 @@ fn placements<F: Fn(f64, f64) -> Rig>(mk: F) -> Vec<Rig> {
vec![mk(1.0, 0.0), mk(-1.0, 0.0), mk(1.0, 28.0), mk(-1.0, 28.0)]
}
/// The shots of event `i`.
fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
/// Shortest shot worth cutting to, in replay ticks.
const MIN_SHOT: f64 = 8.0;
/// The replay tick a shot ends at.
fn shot_end(s: &Shot) -> f64 {
s.table.last().copied().unwrap_or(0.0)
}
/// The shots of event `i`, with replay time never running back: no shot starts before `floor` (the
/// end of the one before it, or of the previous event's last), a stage the real intervals leave no
/// room for is merged into its neighbour or left out, and what happens in the story comes in the
/// order it happened (a launch before its kill; an interceptor not before its first point).
fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
let e = rec.events[i];
let te = e.at as f64;
let o = rec.origin;
let end = rec.len as f64;
let pos = e.pos;
let mut shots: Vec<Shot> = Vec::new();
let mut add = |shot: Shot| shots.push(shot);
let cur = std::cell::Cell::new(floor);
let mut add = |shot: Shot| {
cur.set(shot_end(&shot));
shots.push(shot);
};
// The interval a stage may use: not before the cursor, and long enough to be a shot.
let span = |a: f64, b: f64| -> Option<(f64, f64)> {
let a = a.max(0.0).max(cur.get());
(b - a >= MIN_SHOT).then_some((a, b))
};
// The slow moment: half speed into it, a tenth through it, back to normal after.
let slow = |lead: f64, after: f64| -> Vec<(f64, f64)> {
vec![(te - lead, 0.6), (te - 4.0, 0.15), (te + 6.0, 0.15), (te + 22.0, 0.6), (te + after, 1.0)]
};
let kill_close = |from: DVec3, aim_id: Option<u32>, head: &str, sub: String| -> Shot {
let a = (te - 10.0).max(0.0);
// Never before the story so far; held to at least a few ticks.
let a = (te - 10.0).max(0.0).max(cur.get());
let b = (te + 48.0).min(end).max(a + 4.0);
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
@@ -272,8 +293,7 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
Shot::new(a, b, &slow(10.0, 48.0), variants, head, &sub, i)
};
let aftermath = |from: DVec3, head: &str| -> Option<Shot> {
let a = (te + 48.0).min(end);
let b = (te + 150.0).min(end);
let (a, b) = span((te + 48.0).min(end), (te + 150.0).min(end))?;
if b - a < 20.0 {
return None;
}
@@ -288,67 +308,90 @@ fn plan_event(rec: &Recording, i: usize) -> 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)) => {
// The hero: the launcher, three-quarters, a little before the launch.
let a = (tl - 60.0).max(0.0);
let b = tl + 6.0;
let launcher = sp + DVec3::Y * 4.0;
let variants = placements(|side, lift| {
let dir = (pos - sp).with_y(0.0).normalize_or(DVec3::X);
let s = across(pos, sp) * side;
let base = sp - dir * 38.0 + s * 34.0;
Rig {
eye: Eye::Dolly(base + DVec3::Y * (7.0 + lift), base + s * 6.0 - dir * 4.0 + DVec3::Y * (9.0 + lift)),
aim: Aim::Point(launcher),
fov: (46.0, 40.0),
}
});
add(Shot::new(a, b.max(a + 20.0), &[(a, 1.0)], variants, &name, &sub, i));
// The launch, from the side, the camera low and the interceptor climbing away.
let a2 = b.max(a + 20.0);
let b2 = (a2 + 44.0).min(te - 20.0).max(a2 + 10.0);
let variants = placements(|side, lift| {
let s = across(pos, sp) * side;
Rig { eye: Eye::Static(sp + s * 46.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(m, sp), fov: (42.0, 24.0) }
});
add(Shot::new(a2, b2, &[(a2, 1.0)], variants, "Пуск", "", i));
// The flight, from behind the interceptor with the target ahead; long flights run faster.
let a3 = b2;
let b3 = te - 32.0;
if b3 - a3 > 14.0 {
let sp3 = (((b3 - a3) / 6.0) / 20.0).clamp(1.0, 4.0);
let variants = placements(|side, lift| Rig {
eye: Eye::Behind { track: m, back: 15.0, up: 3.5 + lift * 0.2, side: 5.0 * side },
aim: Aim::Track(drone, pos),
fov: (56.0, 56.0),
// The facts, in order: the launcher waits, the interceptor leaves it at `tl`, it
// flies, it meets the target at `te`. Each stage gets the time the real flight
// has for it; what has none is merged into the one before, or left out.
let tl = tl.min(te);
// The hero: the launcher, three-quarters, until the launch.
if let Some((a, b)) = span(tl - 60.0, tl) {
let launcher = sp + DVec3::Y * 4.0;
let variants = placements(|side, lift| {
let dir = (pos - sp).with_y(0.0).normalize_or(DVec3::X);
let s = across(pos, sp) * side;
let base = sp - dir * 38.0 + s * 34.0;
Rig {
eye: Eye::Dolly(base + DVec3::Y * (7.0 + lift), base + s * 6.0 - dir * 4.0 + DVec3::Y * (9.0 + lift)),
aim: Aim::Point(launcher),
fov: (46.0, 40.0),
}
});
add(Shot::new(a3, b3, &[(a3, sp3)], variants, "Сближение", "", i));
add(Shot::new(a, b, &[(a, 1.0)], variants, &name, &sub, i));
}
// The flight proper runs from the launch to ten ticks before the kill (where the
// close-up begins); under 6 ticks of it there is no room for more than the close-up.
let from = tl.max(cur.get());
let ke = te - 10.0;
let room = ke - from;
if room >= 6.0 {
let launch_len = (room * 0.4).clamp(6.0, 44.0);
let mut b2 = from + launch_len;
let rest = ke - b2;
// The launch, from the side, the camera low and the interceptor climbing away.
let launch_at = |b2: f64| {
let variants = placements(|side, lift| {
let s = across(pos, sp) * side;
Rig { eye: Eye::Static(sp + s * 46.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(m, sp), fov: (42.0, 24.0) }
});
Shot::new(from, b2, &[(from, 1.0)], variants, "Пуск", "", i)
};
if rest < 14.0 {
// Too short to fly on camera: the launch shot runs to the close-up.
b2 = ke;
add(launch_at(b2));
} else {
add(launch_at(b2));
// The flight, from behind the interceptor with the target ahead; long flights run faster.
let b3 = if rest >= 36.0 { te - 32.0 } else { ke };
let sp3 = (((b3 - b2) / 6.0) / 20.0).clamp(1.0, 4.0);
let variants = placements(|side, lift| Rig {
eye: Eye::Behind { track: m, back: 15.0, up: 3.5 + lift * 0.2, side: 5.0 * side },
aim: Aim::Track(drone, pos),
fov: (56.0, 56.0),
});
add(Shot::new(b2, b3, &[(b2, sp3)], variants, "Сближение", "", i));
// Ahead of the target, looking back at the interceptor closing.
if rest >= 36.0 {
let variants = placements(|side, lift| Rig {
eye: Eye::Ahead { track: drone, ahead: 48.0, up: 6.0 + lift * 0.3, side: 14.0 * side },
aim: Aim::Mid(m, drone, pos),
fov: (46.0, 38.0),
});
add(Shot::new(b3, ke, &[(b3, 0.6)], variants, "", "", i));
}
}
}
// Ahead of the target, looking back at the interceptor closing.
let a4 = te - 32.0;
let b4 = te - 10.0;
let variants = placements(|side, lift| Rig {
eye: Eye::Ahead { track: drone, ahead: 48.0, up: 6.0 + lift * 0.3, side: 14.0 * side },
aim: Aim::Mid(m, drone, pos),
fov: (46.0, 38.0),
});
add(Shot::new(a4.max(0.0), b4.max(a4 + 6.0), &[(a4, 0.6)], variants, "", "", i));
}
None => {
// A gun kill: the gun's view of the target.
let a = (te - 110.0).max(0.0);
let b = te - 10.0;
let variants = placements(|side, lift| {
let s = across(pos, sp) * side;
Rig { eye: Eye::Static(sp + s * 14.0 + DVec3::Y * (5.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (34.0, 18.0) }
});
add(Shot::new(a, b.max(a + 12.0), &[(a, 1.0)], variants, &name, &sub, i));
// A gun kill: the gun's view of the target (if the recording has room before it).
if let Some((a, b)) = span(te - 110.0, te - 10.0) {
let variants = placements(|side, lift| {
let s = across(pos, sp) * side;
Rig { eye: Eye::Static(sp + s * 14.0 + DVec3::Y * (5.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (34.0, 18.0) }
});
add(Shot::new(a, b, &[(a, 1.0)], variants, &name, &sub, i));
}
}
}
let high = (pos.y - sp.y).max(0.0);
@@ -360,20 +403,21 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => {
let from = rec.track(drone).and_then(|t| t.at(o, te - 60.0)).unwrap_or(o);
let head = title(rec, i);
let a = (te - 150.0).max(0.0);
let b = te - 55.0;
let variants = placements(|side, lift| Rig {
eye: Eye::Behind { track: drone, back: 16.0, up: 4.0 + lift * 0.2, side: 6.0 * side },
aim: Aim::Point(pos),
fov: (55.0, 55.0),
});
add(Shot::new(a, b.max(a + 16.0), &[(a, 1.4)], variants, &head, "", i));
let a2 = te - 55.0;
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig { eye: Eye::Static(pos + s * 80.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (40.0, 28.0) }
});
add(Shot::new(a2.max(0.0), (te - 10.0).max(a2 + 8.0), &[(a2, 0.8)], variants, "", "", i));
if let Some((a, b)) = span(te - 150.0, te - 55.0) {
let variants = placements(|side, lift| Rig {
eye: Eye::Behind { track: drone, back: 16.0, up: 4.0 + lift * 0.2, side: 6.0 * side },
aim: Aim::Point(pos),
fov: (55.0, 55.0),
});
add(Shot::new(a, b, &[(a, 1.4)], variants, &head, "", i));
}
if let Some((a2, b2)) = span(te - 55.0, te - 10.0) {
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig { eye: Eye::Static(pos + s * 80.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (40.0, 28.0) }
});
add(Shot::new(a2, b2, &[(a2, 0.8)], variants, "", "", i));
}
let sub = match e.kind {
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
format!("сила взрыва: {}", ["хлопок", "средний", "сильный", "тяжёлый", "мощнейший"][(power as usize).min(4)])
@@ -387,12 +431,13 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
}
EventKind::SamLost { .. } => {
let from = pos + DVec3::new(300.0, 0.0, 0.0);
let a = (te - 70.0).max(0.0);
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig { eye: Eye::Dolly(pos + s * 70.0 + DVec3::Y * (12.0 + lift), pos + s * 52.0 + DVec3::Y * (9.0 + lift)), aim: Aim::Point(pos + DVec3::Y * 3.0), fov: (50.0, 44.0) }
});
add(Shot::new(a, (te - 10.0).max(a + 10.0), &[(a, 1.0)], variants, &title(rec, i), "", i));
if let Some((a, b)) = span(te - 70.0, te - 10.0) {
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig { eye: Eye::Dolly(pos + s * 70.0 + DVec3::Y * (12.0 + lift), pos + s * 52.0 + DVec3::Y * (9.0 + lift)), aim: Aim::Point(pos + DVec3::Y * 3.0), fov: (50.0, 44.0) }
});
add(Shot::new(a, b, &[(a, 1.0)], variants, &title(rec, i), "", i));
}
add(kill_close(from, None, "Комплекс уничтожен", String::new()));
if let Some(s) = aftermath(from, "") {
add(s);
@@ -400,14 +445,14 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
}
EventKind::Split { .. } => {
let ground = DVec3::new(pos.x, o.y, pos.z);
let a = (te - 40.0).max(0.0);
let b = (te + 170.0).min(end);
let a = (te - 40.0).max(0.0).max(cur.get());
let b = (te + 170.0).min(end).max(a + 20.0);
let variants = placements(|side, lift| Rig {
eye: Eye::Dolly(ground + DVec3::new(170.0 * side, 8.0 + lift, 40.0), ground + DVec3::new(150.0 * side, 14.0 + lift, 20.0)),
aim: Aim::Point(pos.lerp(ground, 0.4)),
fov: (60.0, 52.0),
});
add(Shot::new(a, b.max(a + 20.0), &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i));
add(Shot::new(a, b, &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i));
}
}
shots
@@ -421,8 +466,14 @@ impl Film {
return None;
}
let mut shots: Vec<Shot> = Vec::new();
// Replay time only moves on through the film: an event's shots start where the last ended.
let mut floor = 0.0;
for i in events {
shots.extend(plan_event(rec, i));
let planned = plan_event(rec, i, floor);
if let Some(last) = planned.last() {
floor = shot_end(last);
}
shots.extend(planned);
}
let mut start = 0.0;
for s in &mut shots {
@@ -573,3 +624,67 @@ impl Film {
format!("{} моментов, {} планов, {:.0} с", events.len(), self.shots.len(), self.len)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::record::{Event, What};
use crate::sim::{DroneKind, MissileKind, SamKind};
/// A recording with one interception: the interceptor leaves at `launch`, the kill is at `kill`.
fn intercept(len: u32, launch: u32, kill: u32, missile: bool) -> Recording {
let mut rec = Recording::blank(len);
let pos = DVec3::new(0.0, 100.0, 300.0);
rec.sams.push((5, SamKind::S400, DVec3::new(0.0, 64.0, 0.0)));
rec.straight(1, What::Drone(DroneKind::Geran3), 0, kill + 20, DVec3::new(0.0, 100.0, 900.0), pos);
if missile {
rec.straight(2, What::Missile(MissileKind::M48n6, 5), launch, kill, DVec3::new(0.0, 66.0, 0.0), pos);
}
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), outcome: Default::default() },
});
rec
}
/// (head, start, end) of each shot in replay ticks.
fn spans(rec: &Recording, floor: f64) -> Vec<(String, f64, f64)> {
plan_event(rec, 0, floor).iter().map(|s| (s.head.clone(), s.table[0], shot_end(s))).collect()
}
// #77: the shots follow the real intervals: replay time never runs back, the launch is not after
// the kill, nothing about the interceptor is shown before its first point.
#[test]
fn a_short_interception_is_cut_in_the_order_it_happened() {
// The issue's case (flight 100 to 105), then 1, 5, 15 and 100+ ticks, the kill near the
// start and near the end of the recording, and a gun kill.
let cases = [(100, 105, true, 400), (100, 101, true, 400), (100, 105, true, 120), (100, 115, true, 400), (100, 260, true, 400), (3, 8, true, 400), (0, 4, true, 30), (0, 300, true, 300), (250, 255, false, 400), (5, 12, false, 400)];
for (launch, kill, missile, len) in cases {
let rec = intercept(len, launch, kill, missile);
let sp = spans(&rec, 0.0);
assert!(!sp.is_empty(), "{launch}->{kill}");
let mut prev_end = 0.0;
for (head, a, b) in &sp {
assert!(b >= a, "{launch}->{kill} {head}: {a}..{b}");
assert!(*a >= prev_end - 1e-9, "{launch}->{kill}: {head} starts at {a}, before the shot before it ended at {prev_end}: {sp:?}");
prev_end = *b;
if head == "Пуск" || head == "Сближение" {
assert!(*a >= launch as f64 - 1e-9 && *b <= kill as f64, "{launch}->{kill}: {head} {a}..{b} outside the flight");
}
}
// The launch comes before the close-up of the kill, which holds the kill itself.
let close = sp.iter().position(|s| s.0 == "Поражение цели").expect("close-up");
assert!(sp[..close].iter().all(|s| s.2 <= kill as f64 + 1e-9), "{launch}->{kill}: {sp:?}");
assert!(sp[close].2 >= kill as f64 || sp[close].2 >= len as f64 - 1.0, "{launch}->{kill}: {sp:?}");
}
}
#[test]
fn a_second_event_does_not_run_the_film_back() {
let rec = intercept(600, 100, 300, true);
// The previous event's last shot ended late: this one starts there.
let sp = spans(&rec, 180.0);
assert!(sp.iter().all(|s| s.1 >= 180.0 - 1e-9), "{sp:?}");
}
}
+164 -19
View File
@@ -13,6 +13,8 @@ use horizon_mod_sdk::{
};
mod cinema;
mod engines;
mod commands;
mod layout;
mod film;
mod monitor;
@@ -81,6 +83,8 @@ struct AirDef {
export: Option<Export>,
/// The effects preset a film replaced: (shader, hd).
film_prev: Option<(bool, bool)>,
/// Counts the replay/film sessions started; an export belongs to the one it began with.
session: u64,
/// The world drawn beyond the render distance (replays and cameras see far off).
far: bool,
/// The game's log of block changes (`render.world-log-start`): a replay shows the world as
@@ -104,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>>,
@@ -130,11 +139,46 @@ enum Stage {
Finishing,
}
struct Export {
/// The session (`AirDef::session`) whose film this export records.
session: u64,
opts: ExportOpts,
stage: Stage,
ticks: u32,
}
/// Whether a replay or film may be started now: not while an export records (stop it first).
fn switch_allowed(exporting: bool) -> Result<(), String> {
if exporting {
Err("Экспорт идёт: дождитесь конца или /airdef replay stop".into())
} else {
Ok(())
}
}
/// 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,
@@ -426,13 +470,18 @@ impl AirDef {
/// The searchlights' spot lights (HD): a cone from the head along its real direction, as far
/// as the beam runs free; at most 16, the renderer draws the nearest.
fn spots(&self) -> Vec<SpotLight> {
if self.shown().replay {
return Vec::new();
}
self.sim
.sams
.iter()
.filter_map(|s| s.spot_beam())
let shown = self.shown();
// A replay lights with the recording's searchlights, where and as they pointed.
let beams: Vec<sim::lights::Beam> = if shown.replay {
match self.replay.as_ref().and_then(|r| self.sim.replay(r.rec).map(|rec| rec.beams(f64::from(shown.now)))) {
Some(b) => b,
None => Vec::new(),
}
} else {
self.sim.sams.iter().filter_map(|s| s.spot_beam()).collect()
};
beams
.into_iter()
.take(16)
.map(|b| SpotLight {
id: b.id,
@@ -536,13 +585,11 @@ impl AirDef {
if !shown.replay {
return (self.sim.tick as f64, self.smoke.segments(&self.sim));
}
let blasts = std::mem::replace(&mut self.sim.blasts, shown.blasts);
let fires = std::mem::take(&mut self.sim.fires.list);
let tick = std::mem::replace(&mut self.sim.tick, shown.now as u64);
let segments = smoke::Smoke::default().segments(&self.sim);
self.sim.blasts = blasts;
self.sim.fires.list = fires;
self.sim.tick = tick;
// The recording alone, as a function of its time: the live Sim is not touched.
let segments = match self.sim.replay(self.replay.as_ref().map_or(0, |r| r.rec)) {
Some(rec) => smoke::replay_smoke(rec, f64::from(shown.now)),
None => Vec::new(),
};
(f64::from(shown.now), segments)
}
@@ -598,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));
}
@@ -962,6 +1061,7 @@ impl AirDef {
/// Plays the last recorded raid: "map", "full", "reel" or (with `event`) a clip.
fn start_replay(&mut self, what: &str, event: Option<usize>) -> Result<(), String> {
switch_allowed(self.export.is_some())?;
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
@@ -980,7 +1080,11 @@ impl AirDef {
_ => replay::Mode::Map,
};
self.say_block_log(rec);
self.replay = Some(replay::Player::new(rec.number, mode, &self.sim));
let player = replay::Player::new(rec.number, mode, &self.sim);
// A film (its quality and effects) or another replay ends properly before this one.
self.end_session();
self.session += 1;
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
Ok(())
@@ -991,6 +1095,12 @@ impl AirDef {
render::capture_stop(true);
say("Экспорт отменён");
}
self.end_session();
}
/// Ends whatever replay or film shows, on every path: the film quality, the effects, the
/// clean HUD, the views and the time and camera overrides go back to the live game's.
fn end_session(&mut self) {
render::set_film_quality(false);
if let Some((shader, hd)) = self.film_prev.take() {
self.set_effects(shader, hd);
@@ -1007,6 +1117,9 @@ impl AirDef {
/// Plays the director's film of the last raid at the best quality: HD effects on (restored after).
fn start_film(&mut self, which: &str, export: Option<&ExportOpts>) -> Result<(), String> {
if export.is_none() {
switch_allowed(self.export.is_some())?;
}
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
@@ -1025,6 +1138,9 @@ impl AirDef {
player.clean = true;
player.limit = o.seconds;
}
// What was shown ends first (its effects are remembered below, not lost).
self.end_session();
self.session += 1;
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
@@ -1068,13 +1184,20 @@ impl AirDef {
let which = o.which.clone();
self.start_film(&which, Some(&o))?;
say(&format!("Экспорт «{}»: {} fps, {}x{}; планирую кадры…", o.name, o.fps, o.size.0, o.size.1));
self.export = Some(Export { opts: o, stage: Stage::Planning, ticks: 0 });
self.export = Some(Export { session: self.session, opts: o, stage: Stage::Planning, ticks: 0 });
Ok(())
}
/// Drives an export: plan, start the recorder, wait for its first frame, report, stop at the end.
fn export_step(&mut self, r: &mut replay::Player) {
let Some(ex) = &mut self.export else { return };
if ex.session != self.session {
// The film this export recorded was replaced: the recorder stops, nothing else drives it.
render::capture_stop(true);
self.export = None;
say("Экспорт отменён: показ заменён другим");
return;
}
ex.ticks += 1;
let st = render::capture_status();
let failed = |s: &render::CaptureState| s.phase == render::CapturePhase::Failed;
@@ -1328,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> {
@@ -1349,6 +1475,7 @@ impl Mod for AirDef {
time_view: false,
export: None,
film_prev: None,
session: 0,
smoke: Default::default(),
far: true,
moving: None,
@@ -1359,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(),
@@ -1381,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()))
}
@@ -1500,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);
@@ -1905,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)
@@ -1913,3 +2046,15 @@ impl Mod for AirDef {
}
horizon_mod_sdk::export_mod!(AirDef);
#[cfg(test)]
mod tests {
use super::*;
/// #83: while an export records no other replay or film replaces it; after it, they may.
#[test]
fn nothing_replaces_a_running_export() {
assert!(switch_allowed(true).is_err());
assert!(switch_allowed(false).is_ok());
}
}
+239 -12
View File
@@ -13,6 +13,17 @@ use crate::sim::{Class, DroneKind, Out, Sim};
use glam::DVec3;
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape};
/// Metres the camera keeps above the surface (land or water) it stands over.
const SURFACE_CLEAR: f64 = 2.5;
/// `eye` lifted above the surface under it (`Ground::land_state`: unanswered or no land: unchanged).
fn above_surface(eye: DVec3, land: Option<Option<f64>>) -> DVec3 {
match land {
Some(Some(h)) if eye.y < h + SURFACE_CLEAR => DVec3::new(eye.x, h + SURFACE_CLEAR, eye.z),
_ => eye,
}
}
/// Ghost models' ids, clear of the live ones.
const GHOSTS: u32 = 0x5000_0000;
/// The camera view's id (a new one each cut, so the game doesn't sweep between shots).
@@ -57,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).
@@ -114,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),
@@ -135,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),
@@ -152,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,
@@ -168,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 }
@@ -243,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,
@@ -419,8 +444,50 @@ impl Player {
return;
}
sim.draw_ghosts(rec, self.t, GHOSTS, out);
self.direct(rec);
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.
@@ -428,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
@@ -487,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;
}
@@ -565,7 +636,7 @@ impl Player {
}
/// The event the camera films now: (eye, look, fov, shot).
fn shoot(&mut self, rec: &Recording, i: usize) -> (DVec3, DVec3, f32, u8) {
fn shoot(&mut self, rec: &Recording, i: usize, ground: &mut Ground) -> (DVec3, DVec3, f32, u8) {
let e = rec.events[i];
let t = self.t;
let te = e.at as f64;
@@ -602,6 +673,10 @@ impl Player {
None => {
// A gun kill, from the gun.
let at = rec.sam(sam).map_or(e.pos + DVec3::Y * 20.0, |s| s.1 + DVec3::Y * 4.0);
// A system on the sea floor (or a lake's) must not film from under the water:
// the game's ground height is the top of the highest block that blocks motion
// or holds a fluid, i.e. the water's surface there.
let at = above_surface(at, ground.land_state(at));
let dist = target.distance(at).max(1.0);
let fov = (2.0 * (10.0 / dist).atan()).to_degrees().clamp(6.0, 60.0) as f32;
(at, target, fov, 1)
@@ -630,7 +705,7 @@ impl Player {
}
/// Chooses the shot and moves the camera; a new shot is a cut.
fn direct(&mut self, rec: &Recording) {
fn direct(&mut self, rec: &Recording, ground: &mut Ground) {
let focus = match &self.mode {
Mode::Clips { list, at, .. } => Some(list[*at].event),
// The full replay follows what comes next: the next event within 6 s, else the drone
@@ -644,7 +719,7 @@ impl Player {
};
let (eye, look, fov, shot) = match focus {
Some(i) => {
let s = self.shoot(rec, i);
let s = self.shoot(rec, i, ground);
(s.0, s.1, s.2, (i, s.3))
}
None => {
@@ -957,3 +1032,155 @@ impl Player {
Mode::Clips { list: vec![clip(rec, i)], at: 0, reel: false }
}
}
#[cfg(test)]
mod surface_tests {
use super::*;
/// A gun standing on the sea floor films from above the water, not from under it.
#[test]
fn the_gun_camera_is_lifted_above_the_water() {
let eye = DVec3::new(10.0, 44.0, 5.0); // 4 above a Gepard on the floor at 40
let lifted = above_surface(eye, Some(Some(62.0))); // the surface
assert!(lifted.y >= 62.0 + SURFACE_CLEAR - 1e-9);
assert_eq!((lifted.x, lifted.z), (10.0, 5.0));
// On dry land, or with no answer yet, the camera stays where it was.
assert_eq!(above_surface(DVec3::new(0.0, 74.0, 0.0), Some(Some(70.0))), DVec3::new(0.0, 74.0, 0.0));
assert_eq!(above_surface(eye, None), eye);
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());
}
+160 -6
View File
@@ -18,6 +18,8 @@ pub struct SamKey {
pitch: f32,
/// The antenna's turn.
dish: f32,
/// A searchlight's beam: how far it runs free (to 8 blocks), 0 when it is off.
beam: f32,
}
impl SamKey {
@@ -30,6 +32,7 @@ impl SamKey {
yaw: sam.yaw as f32,
pitch: sam.pitch as f32,
dish: sam.dish as f32,
beam: if sam.beam_on { ((sam.beam_free / 8.0).round() * 8.0).max(8.0) as f32 } else { 0.0 },
}
}
/// The same pose (to within what could be seen).
@@ -40,6 +43,7 @@ impl SamKey {
&& near(self.yaw, o.yaw, 0.002)
&& near(self.pitch, o.pitch, 0.002)
&& near(self.dish, o.dish, 0.002)
&& self.beam == o.beam
}
}
@@ -79,6 +83,7 @@ impl SamTrack {
yaw: turn(a.yaw, b.yaw, f),
pitch: l(a.pitch, b.pitch),
dish: turn(a.dish, b.dish, f),
beam: a.beam,
})
}
/// The system as it stood at `t`, as a ghost to draw with the ids from `id`.
@@ -89,11 +94,54 @@ impl SamTrack {
sam.yaw = k.yaw as f64;
sam.pitch = k.pitch as f64;
sam.dish = k.dish as f64;
sam.beam_on = k.beam > 0.0;
sam.beam_free = k.beam as f64;
sam.health = if self.lost.is_some_and(|l| t >= l as f64) { 0.0 } else { 1.0 };
Some(sam)
}
}
#[cfg(test)]
impl Recording {
/// A recording of `len` ticks with nothing in it (for the planner's tests).
pub(crate) fn blank(len: u32) -> Self {
Recording {
number: 1,
origin: DVec3::ZERO,
len,
tracks: Vec::new(),
index: HashMap::new(),
events: Vec::new(),
tally: Tally::default(),
sams: Vec::new(),
objects: Vec::new(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
}
}
/// A straight track from `from` to `to` over the ticks `first..=last` (every `STEP`).
pub(crate) fn straight(&mut self, id: u32, what: What, first: u32, last: u32, from: DVec3, to: DVec3) {
let n = ((last - first) / STEP).max(1) as usize;
let points = (0..=n).map(|k| {
let p = from.lerp(to, k as f64 / n as f64);
[p.x as f32, p.y as f32, p.z as f32]
});
self.index.insert(id, self.tracks.len());
self.tracks.push(Track { id, what, first, points: points.collect(), fell: None });
}
}
impl Recording {
/// The searchlights' beams at `t` as the recording had them (position and direction from the
/// recorded poses, none for a lost light), never the live state.
pub fn beams(&self, t: f64) -> Vec<lights::Beam> {
self.sam_tracks.iter().filter_map(|st| st.ghost(self.origin, t, st.id)?.spot_beam()).collect()
}
}
/// Ticks between a track's points.
pub const STEP: u32 = 2;
/// The longest recording (10 minutes) and how many are kept.
@@ -120,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.
@@ -244,6 +293,10 @@ impl Track {
_ => DVec3::NEG_Z,
}
}
/// Its `i`-th point (every `STEP` ticks from `first`), if it has one.
pub fn sample(&self, origin: DVec3, i: usize) -> Option<DVec3> {
self.points.get(i).map(|q| origin + DVec3::new(q[0] as f64, q[1] as f64, q[2] as f64))
}
/// The tick of its last point.
pub fn last(&self) -> u32 {
self.first + (self.points.len().max(1) as u32 - 1) * STEP
@@ -279,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.
@@ -288,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, .. } => {
@@ -529,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);
@@ -587,6 +641,13 @@ impl Sim {
units::draw_sam_model(&ghost, &mut rng, out);
}
}
// The protected objects as marked in the raid (struck ones shrink), not the live ones.
for (i, p) in rec.objects.iter().enumerate() {
let struck = rec.events.iter().any(|e| e.at as f64 <= t && matches!(e.kind, EventKind::Hit { object: Some(o), .. } if o == i));
let bob = (t * 0.1 + i as f64).sin() * 0.25;
let rot = DQuat::from_rotation_y(t * 0.08);
out.model(base + 0x00e0_0000 + i as u32 * PARTS, "airdef:target", *p + DVec3::Y * (1.6 + bob), rot, if struck { 0.4 } else { 0.8 }, true);
}
// Fires: what the events left burning (the flames dim and the smoke thickens with time),
// out when their life ends.
for f in replay_fires(rec, t) {
@@ -601,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 {
@@ -673,6 +759,32 @@ mod tests {
assert!((pose(100.0).pos[0] - 4.0).abs() < 1e-6);
}
// #76: a replay's searchlights point where the recording had them, from the recorded pose.
#[test]
fn a_replay_has_the_recorded_searchlight_beams() {
let mut r = recording();
let mut light = Sam::new(9, SamKind::Light, DVec3::new(10.0, 64.0, 0.0), 0.0);
light.beam_on = true;
light.beam_free = 300.0;
light.yaw = 1.0;
light.pitch = 0.8;
r.note_sams(&[light], 0);
// Swung round and switched off later.
let mut later = Sam::new(9, SamKind::Light, DVec3::new(10.0, 64.0, 0.0), 0.0);
later.yaw = 2.0;
later.pitch = 0.8;
r.note_sams(&[later], 100);
assert!(r.beams(-1.0).is_empty(), "nothing before it was seen");
let b = r.beams(0.0);
assert_eq!(b.len(), 1);
assert_eq!(b[0].id, 9);
assert!((b[0].free - 304.0).abs() < 1e-6 || (b[0].free - 296.0).abs() < 1e-6, "free {}", b[0].free);
assert!(b[0].dir.y > 0.5, "pointing up: {:?}", b[0].dir);
assert!(r.beams(100.0).is_empty(), "switched off");
r.sam_tracks[0].lost = Some(50);
assert!(r.beams(10.0).len() == 1 && r.beams(60.0).is_empty(), "a lost light has no beam");
}
#[test]
fn a_system_first_seen_later_is_not_drawn_before() {
let mut r = recording();
@@ -708,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");
}
}
+67 -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.
@@ -654,6 +654,69 @@ fn replay_fires_and_blasts_follow_the_recording() {
assert!(record::replay_blasts(&rec, 10.0 + a[0].life as f64).is_empty(), "burnt out");
}
// #78: a replay's smoke is rebuilt from the recording as a function of its time: trails behind
// what flew (clearing the fog), a column over the fire, billows that outlive the fireball; a seek
// shows what playing through shows, and the live Sim is not touched.
#[test]
fn replay_smoke_has_trails_and_fire_columns_and_is_a_function_of_time() {
use record::{Event, EventKind};
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 2000.0));
sim.add_drone(DroneKind::Geran3, DVec3::new(30.0, 80.0, 0.0), DVec3::Z, DVec3::new(30.0, 70.0, 2000.0));
let mut g = Ground::default();
let mut out = Out::default();
for _ in 0..160 {
sim.step(&mut g, None, &mut out);
}
let mut rec = sim.rec.take().unwrap();
assert_eq!(rec.tracks.len(), 2);
rec.events = vec![Event { at: 40, pos: DVec3::new(5.0, 70.0, 5.0), kind: EventKind::Hit { drone: 1, kind: DroneKind::Geran3, power: 2, object: None, sam: None } }];
let live_tick = sim.tick;
let live_fires = sim.fires.list.len();
let t = 130.0;
let a = crate::smoke::replay_smoke(&rec, t);
let trails = a.iter().filter(|s| s.clear > 0.0).count();
let columns = a.iter().filter(|s| s.soot > 0.8).count();
let billows = a.iter().filter(|s| s.soot == 0.55).count();
assert!(trails > 0, "trails of both tracks: {trails}");
assert!(columns > 0, "a column over the fire: {columns}");
assert!(billows > 0, "the blast's billows");
// Seeking away and back, or forward from nothing, gives the same smoke.
let _ = crate::smoke::replay_smoke(&rec, 10.0);
let _ = crate::smoke::replay_smoke(&rec, 155.0);
let b = crate::smoke::replay_smoke(&rec, t);
assert_eq!(a.iter().map(|s| (s.id, s.born as u64)).collect::<Vec<_>>(), b.iter().map(|s| (s.id, s.born as u64)).collect::<Vec<_>>());
// Nothing from the future; before the raid, nothing.
assert!(a.iter().all(|s| s.born <= t));
assert!(crate::smoke::replay_smoke(&rec, -5.0).is_empty());
// The billows go on long after the short fireball is out.
let late = crate::smoke::replay_smoke(&rec, 400.0);
assert!(late.iter().any(|s| s.soot == 0.55));
// The live state is not touched.
assert_eq!((sim.tick, sim.fires.list.len()), (live_tick, live_fires));
}
// #76: the protected objects of a replay are the recording's, not the live marks.
#[test]
fn a_replay_draws_the_recorded_objects_not_the_live_ones() {
let mut sim = Sim::new(1);
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
sim.target_health.push(100.0);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
sim.step(&mut g, None, &mut out);
let rec = sim.rec.take().unwrap();
assert_eq!(rec.objects.len(), 1);
// The live mark is moved away after the raid; the replay still shows it where it was.
sim.targets[0] = DVec3::new(900.0, 70.0, 900.0);
let mut shown = Out::default();
sim.draw_ghosts(&rec, 0.0, 0x1000, &mut shown);
let marks: Vec<_> = shown.models.iter().filter(|m| m.name == "airdef:target").collect();
assert_eq!(marks.len(), 1);
assert!(marks[0].at.x.abs() < 1.0 && marks[0].at.z.abs() < 1.0, "{:?}", marks[0].at);
}
fn sim_first(r: &record::Recording) -> u64 {
r.tracks.first().map_or(0, |t| t.first as u64)
}
@@ -1285,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
}
}
+148 -67
View File
@@ -47,6 +47,150 @@ fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment {
}
}
/// The column over one fire up to `tick`: a puff every 15 ticks (90 for the weaker fires) that
/// rises, spreads and thins. A pure function of the tick.
fn fire_column(out: &mut Vec<SmokeSegment>, fire_id: u32, at: DVec3, start: u64, size: f32, shown: bool, tick: u64, level: &dyn Fn(u64) -> (f32, f32)) {
// Only the strongest fires get a full column; the rest a thin puff now and then.
let step = if shown { 15 } else { 90 };
let first = start.max(tick.saturating_sub(520));
let mut k = first.div_ceil(step) * step;
while k <= tick && out.len() < MAX_TOTAL {
let (flame, smoke) = level(k);
if smoke > 0.05 && k >= start {
let id = 0x4000_0000 | (fire_id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
let at = at + DVec3::Y * 0.6;
let mut s = seg(id, at, at, k, 540);
s.radius_start = 1.0 * size;
s.radius_end = 8.0 * size + 2.0;
s.density = 2.6 * smoke * size.sqrt() * if shown { 1.0 } else { 0.5 };
s.heat = flame * 0.6;
s.soot = 0.85;
s.rise = 2.6;
out.push(s);
}
k += step;
}
}
/// Blasts: a billow of puffs going up, for 900 ticks after each.
fn blast_billows(out: &mut Vec<SmokeSegment>, blasts: &[crate::sim::Blast], tick: u64) {
for b in blasts {
let age = tick.saturating_sub(b.start);
if age > 900 {
continue;
}
for j in 0..6u64 {
let born = b.start + j * 3;
if born > tick {
break;
}
let h = b.size as f64 * (0.2 + 0.5 * j as f64);
let at = b.at + DVec3::Y * h;
let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800);
s.radius_start = b.size * 0.6;
s.radius_end = b.size * 3.2 + 4.0;
s.density = 2.4 / (1.0 + j as f32 * 0.25);
s.heat = if j < 3 { 1.0 } else { 0.4 };
s.soot = 0.55;
s.rise = 4.0 - 0.3 * j as f32;
out.push(s);
}
}
}
fn style_drone(s: &mut SmokeSegment, class: Class) {
let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class {
Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0),
Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0),
Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0),
Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0),
};
s.radius_start = r0;
s.radius_end = r1;
s.density = dens;
s.heat = heat;
s.soot = soot;
s.clear = clear;
s.life = life;
}
fn style_missile(s: &mut SmokeSegment) {
s.radius_start = 0.6;
s.radius_end = 5.0;
s.density = 3.0;
s.heat = 0.8;
s.soot = 0.05;
s.clear = 3.0;
s.life = 1000.0;
}
/// Points between a replay trail segment's ends (a segment every `TRAIL_GRID * STEP` ticks).
const TRAIL_GRID: usize = 4;
/// The smoke a recording shows at its tick `t`: trails behind everything that flew, columns over
/// the fires its events lit, billows over its blasts. A pure function of `t` (nothing is kept
/// between calls, so a seek, a pause or a cut shows exactly what playing through would), and it
/// touches no live state. Trail segments sit on a fixed grid of each track's points.
pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSegment> {
use crate::sim::record::{STEP, What};
let tick = t.max(0.0) as u64;
let mut out: Vec<SmokeSegment> = Vec::new();
let stride = (TRAIL_GRID as u32 * STEP) as f64;
for (n, tr) in rec.tracks.iter().enumerate() {
let (life, style): (f64, Box<dyn Fn(&mut SmokeSegment)>) = match tr.what {
What::Drone(kind) => {
let class = kind.spec().class;
let mut probe = seg(0, DVec3::ZERO, DVec3::ZERO, 0, 0);
style_drone(&mut probe, class);
(probe.life as f64, Box::new(move |s| style_drone(s, class)))
}
What::Missile(..) => (1000.0, Box::new(style_missile)),
};
// The segments whose end point has been reached and whose life is not over.
let lo = (((t - life - tr.first as f64) / stride).floor().max(0.0)) as usize;
let hi = (((t - tr.first as f64) / stride).floor().max(0.0)) as usize;
for g in lo.max(1)..=hi {
let (Some(a), Some(b)) = (tr.sample(rec.origin, (g - 1) * TRAIL_GRID), tr.sample(rec.origin, g * TRAIL_GRID)) else { break };
let born = tr.first as u64 + (g * TRAIL_GRID) as u64 * STEP as u64;
if born > tick || a.distance(b) >= 400.0 {
continue;
}
let mut s = seg(0x2000_0000 | ((n as u32).wrapping_mul(7919) ^ g as u32) & 0x0fff_ffff, a, b, born, 1200);
style(&mut s);
if (tick as f64) < born as f64 + s.life as f64 {
out.push(s);
}
}
}
if out.len() > MAX_TRAIL {
out.sort_by_key(|s| s.born as u64);
let cut = out.len() - MAX_TRAIL;
out.drain(..cut);
}
// Fires, the strongest dozen in full (as the live ones are).
let mut fires = crate::sim::record::replay_fires(rec, t);
fires.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.start.cmp(&b.start)));
for (rank, f) in fires.iter().enumerate() {
let life = f.life as f64;
fire_column(&mut out, 0x1000_0000 | f.key as u32, f.at, f.start as u64, f.size, rank < crate::sim::fires::MAX, tick, &|k| crate::sim::fires::level(k.saturating_sub(f.start as u64) as f64, life));
}
// Blasts keep their billows long after the fireball is out.
let blasts: Vec<crate::sim::Blast> = rec
.events
.iter()
.enumerate()
.filter(|(_, e)| e.at as u64 <= tick)
.map(|(k, e)| {
let (size, life) = crate::sim::record::blast_spec(e.kind);
crate::sim::Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life }
})
.collect();
blast_billows(&mut out, &blasts, tick);
out.truncate(MAX_TOTAL);
out
}
impl Smoke {
fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, style: impl Fn(&mut SmokeSegment)) {
let Some(&(from, when)) = self.last.get(&key) else {
@@ -76,32 +220,10 @@ impl Smoke {
self.last_tick = tick;
for d in &sim.drones {
let class = d.kind.spec().class;
let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class {
Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0),
Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0),
Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0),
Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0),
};
self.trail((0, d.id), d.pos, tick, |s| {
s.radius_start = r0;
s.radius_end = r1;
s.density = dens;
s.heat = heat;
s.soot = soot;
s.clear = clear;
s.life = life;
});
self.trail((0, d.id), d.pos, tick, |s| style_drone(s, class));
}
for m in &sim.missiles {
self.trail((1, m.id), m.pos, tick, |s| {
s.radius_start = 0.6;
s.radius_end = 5.0;
s.density = 3.0;
s.heat = 0.8;
s.soot = 0.05;
s.clear = 3.0;
s.life = 1000.0;
});
self.trail((1, m.id), m.pos, tick, style_missile);
}
// Forget what is gone.
if tick % 100 == 0 {
@@ -129,50 +251,9 @@ impl Smoke {
if f.quiet && f.size < 1.0 {
continue;
}
// Only the strongest fires get a full column; the rest a thin puff now and then.
let step = if f.shown { 15 } else { 90 };
let first = f.start.max(tick.saturating_sub(520));
let mut k = first.div_ceil(step) * step;
while k <= tick && out.len() < MAX_TOTAL {
let (flame, smoke) = f.level(k);
if smoke > 0.05 && k >= f.start {
let id = 0x4000_0000 | (f.id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
let at = f.at + DVec3::Y * 0.6;
let mut s = seg(id, at, at, k, 540);
s.radius_start = 1.0 * f.size;
s.radius_end = 8.0 * f.size + 2.0;
s.density = 2.6 * smoke * f.size.sqrt() * if f.shown { 1.0 } else { 0.5 };
s.heat = flame * 0.6;
s.soot = 0.85;
s.rise = 2.6;
out.push(s);
}
k += step;
}
}
// Blasts: a billow of puffs going up.
for b in &sim.blasts {
let age = tick.saturating_sub(b.start);
if age > 900 {
continue;
}
for j in 0..6u64 {
let born = b.start + j * 3;
if born > tick {
break;
}
let h = b.size as f64 * (0.2 + 0.5 * j as f64);
let at = b.at + DVec3::Y * h;
let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800);
s.radius_start = b.size * 0.6;
s.radius_end = b.size * 3.2 + 4.0;
s.density = 2.4 / (1.0 + j as f32 * 0.25);
s.heat = if j < 3 { 1.0 } else { 0.4 };
s.soot = 0.55;
s.rise = 4.0 - 0.3 * j as f32;
out.push(s);
}
fire_column(&mut out, f.id, f.at, f.start, f.size, f.shown, tick, &|k| f.level(k));
}
blast_billows(&mut out, &sim.blasts, tick);
out.truncate(MAX_TOTAL);
out
}
+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).
+13 -3
View File
@@ -233,6 +233,9 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
| #68: шум облаков по Y — от низа слоя, а не от камеры; шкалы тумана 170→128, дыма 22→32, луж 0.45/1.3→0.5/1.25 делят период обёртки 65536/256; тест `noise_scales_divide_the_camera_wrap` | готово | в рабочем дереве форка |
| #70: `Game::sky_darken` — всегда реальное время мира (ИИ, спавн, блоки, статистика); `shown_sky_darken` (время реплея) — только тени сущностей и свет глаза; небо/луна/цвета остаются по показанному времени | готово (собрано, игровой проверки день/ночь реплея нет) | в рабочем дереве форка |
| Объёмный дым и следы ракет (#54, п. 8 плана): API уровня 14 `render.set-smoke`, сетка дыма 128×64×128 по 3 блока (`gfx/hd/smoke.rs`, `smoke_grid.wgsl`), марш в проходе тумана, «туннель» в тумане и облаках, `HORIZON_HD_SMOKE=0`; мод: `airdef-mod/src/smoke.rs` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено |
| Цветные тени (план 6): ближний каскад без листвы в глубине + карта цвета 1024² (`Rgba16Float`, `shadow_tint_pass`): стекло/лёд умножают свет на цвет, листва пропускает ×0.55 зелёного; в альфе расстояние до источника света (min-blend); `fs_light` берёт 8 отсчётов (`shadow_tint`); `HORIZON_HD_COLOR_SHADOW=0` — выкл. | готово; радуга от цветного стекла видна в игре, листва — слабым зеленоватым пятном; без A/B с выключенным | в рабочем дереве форка |
| Рябь от дождя на воде (остаток плана 7): кольца в сетке 1.6 блока (`ripple_gradient`, `water.wgsl`), уровень дождя в `sky_extra.w`; `HORIZON_HD_RAIN_RIPPLES=0` — выкл. | готово; в игре едва различимы дуги в бликах (частицы дождя закрывают), не настраивалось | в рабочем дереве форка |
| Motion blur и DoF кинокамеры (план 9): `gfx/hd/film.rs` + `film.wgsl`, проход после TAA, только при `film_quality()`; `HORIZON_HD_MOTION_BLUR=0`, `HORIZON_HD_DOF=0`, `HORIZON_HD_DOF_FOCUS`, `HORIZON_HD_DOF_STRENGTH`, `HORIZON_HD_FILM_QUALITY=1` (принудительно, для проб) | DoF виден в игре (до правки стоил 14–17 мс); motion blur в игре не подтверждён; правка стоимости DoF (планарная глубина, 24 отсчёта, радиус 0.7%) собрана и прошла тесты, но глазами не проверена | в рабочем дереве форка |
## План (по порядку) и ТЗ
@@ -253,10 +256,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Перистые и облака в воде сделаны (см. «Облака, перистые, туман по рельефу»).
5. **Блики и материалы** — СДЕЛАНО (см. «Материалы, мокрое, ветер»). Исходное ТЗ: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
мода), мокрые поверхности и лужи в дождь.
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
7. **Ветер** — СДЕЛАНО (рябь от дождя на воде осталась). Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
6. **PCSS** и цветные тени через стекло/листву — СДЕЛАНО (см. «Цветные тени, рябь, кино-пост»). Не сделано: цвет в лучах тумана и в тени под водой, цветная тень дальнего каскада.
7. **Ветер** и рябь от дождя — СДЕЛАНО. Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
9. Опционально: motion blur, DoF (для кинокамеры повторов).
9. Motion blur, DoF для кинокамеры — СДЕЛАНО в коде (см. ниже), проверено частично.
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
отражения, затем GI. Отдельный пресет, HD остаётся.
@@ -364,3 +367,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
- **Захват** (`render.capture-start/stop/status`, `game/film.rs`, `Renderer::capture_*`): часы игры идут ровно `1/fps` секунды за кадр (тики, интерполяция, `tick` мода), кадр копируется с GPU через очередь из ≤3 буферов и уходит в поток-писатель → `ffmpeg` (libx264, иначе h264_videotoolbox; иначе PNG-последовательность). Звук: микшер OpenAL-подобный (`audio.rs`) работает офлайн (`film_begin/film_mix`), слышны только звуки мода, ориентация — по камере; дорожка вмуксивается AAC. После склейки рисуются `warmup` кадров без записи, чтобы сошлись TAA и объёмы. Размер: offscreen-игра ресайзится, окно просится (`request_inner_size`).
- Мод-HUD (`set-hud`) теперь рисуется и при `set-main-view` (титры, леттербокс фильма); ванильный HUD по-прежнему скрыт.
- Режиссура: `airdef-mod/src/film.rs` (план по записи, варианты ракурсов, проверка рельефа по высотам `Ground::land_state`, ускорение/замедление времени).
## Цветные тени, рябь, кино-пост: что знать преемнику
- **Цветные тени.** Ближний каскад (c = 0) рисуется `fs_shadow_near` (листва `MAT_LEAVES` отброшена), потом проход `hd shadow colour` (`Hd::shadow_tint_pass`, цель `Shadows::tint`, очистка в белое) рисует листву (`shadow_tint_leaves`, твёрдые квады, `SHADOW_SOLID`) и полупрозрачные квады секций (`shadow_tint_glass`, по отсортированным индексам; вода не рисуется) тем же `fs_shadow_tint`: rgb умножаются (blend `Dst*src`), альфа = `(расстояние от света − 320)/160` с blend Min — ближайший такой квад. Приёмник в `shadow_tint` (`light.wgsl`, привязка 22) окрашивается, если квад ближе к свету, чем он; 8 точек Vogel радиусом 0.4 блока размывают листву в «пятна». Дальний каскад по-прежнему рисует листву полностью (на границе 36 блоков шов: ближе листва светлее). Цвет не доходит до лучей тумана, воды и дальнего каскада. Цена: `hd shadow colour` 0.25–0.35 мс (Mac, малая сцена).
- **Рябь.** `ripple_gradient` берёт часы воды (`water.y`, часы фильма): частоты — целые циклы на 600 с, сетка 160 клеток на 256 блоков, поэтому рисунок не прыгает на обороте. Гаснет по размеру пикселя, только под открытым небом (`nt.a`). Амплитуда 0.012 не подбиралась.
- **Кино-пост.** `Hd::film` между `taa` и `finish` (`HdSource::Film`), только при `film_quality()`; цели создаются при первом фильме. Motion blur — только от движения камеры (глубина + `prev_view_proj`, затвор 180°, мёртвая зона 1–2.5 px из-за джиттера; движущиеся объекты не размываются; нет после склейки/телепорта: `taa.w`). DoF — диск 24 отсчёта, фокус авто (гармоническое среднее 9 глубин у центра) или `HORIZON_HD_DOF_FOCUS`. Детерминизм: единственное время в шейдере — `water.y` (часы фильма) для шума. Проверять на реальном фильме `/airdef replay film`; `HORIZON_HD_FILM_QUALITY=1` принудительно включает качество фильма для проб. DoF в игре был слишком сильным и дорогим; параметры уменьшены без повторного снимка. Motion blur на снимке (поворот 40° за 1.5 с) не разобран.
- Из Photon (MIT) ничего нового не переносилось: THIRD_PARTY_PHOTON.md не менялся.
+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/33] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/38] 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/33] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/38] 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/33] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/38] 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/33] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/38] 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/33] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/38] 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/33] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/38] 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/33] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/38] 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/33] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/38] 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/33] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/38] 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/33] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/38] 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/33] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/38] 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/33] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/38] 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/33] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/38] 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/33] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/38] 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/33] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/38] 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/33] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/38] 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/33] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/38] 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/33] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/38] 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/33] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/38] 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/33] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/38] 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/33] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/38] 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/33] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/38] 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/33] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/38] 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/33] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/38] 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/33] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/38] 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/33] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/38] 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/33] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/38] 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/33] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/38] 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/33] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/38] 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/33] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/38] 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/33] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/38] 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/33] Spot lights for mods (render.set-spot-lights, API level
Subject: [PATCH 32/38] 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/33] HD clouds and fog polish: cloud panorama, cirrus,
Subject: [PATCH 33/38] 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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,446 @@
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/38] Film export: every frame in flight reaches the writer,
listener follows the main view, cloud wind on film frames, set-smoke reports
real activity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/film_clock.rs | 32 +++--
crates/horizon-client/src/game.rs | 25 +++-
crates/horizon-client/src/game/film.rs | 140 ++++++++++++++++---
crates/horizon-client/src/game/mod_models.rs | 6 +-
crates/horizon-client/src/gfx.rs | 2 +-
crates/horizon-client/src/gfx/hd/mod.rs | 4 +-
crates/horizon-client/src/gfx/hd/smoke.rs | 24 ++++
7 files changed, 196 insertions(+), 37 deletions(-)
diff --git a/crates/horizon-client/src/film_clock.rs b/crates/horizon-client/src/film_clock.rs
index 6396e4c1f..f001f0e78 100644
--- a/crates/horizon-client/src/film_clock.rs
+++ b/crates/horizon-client/src/film_clock.rs
@@ -6,26 +6,34 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
-static FILM: Mutex<Option<Instant>> = Mutex::new(None);
+/// While a film records: where its clock began (real time) and how much film time has passed.
+static FILM: Mutex<Option<(Instant, Duration)>> = Mutex::new(None);
-/// The film's moment (frozen for the frame), or None to run on real time again.
-pub fn set(now: Option<Instant>) {
+/// The film's moment (frozen for the frame) as (its origin, film time since it), or None to run on
+/// real time again.
+pub fn set(now: Option<(Instant, Duration)>) {
if let Ok(mut f) = FILM.lock() {
*f = now;
}
}
+fn film() -> Option<(Instant, Duration)> {
+ FILM.lock().ok().and_then(|f| *f)
+}
+
/// What the animations take as now.
pub fn now() -> Instant {
- FILM.lock()
- .ok()
- .and_then(|f| *f)
- .unwrap_or_else(Instant::now)
+ film().map_or_else(Instant::now, |(origin, t)| origin + t)
}
-/// Time since `start` on this clock.
+/// Time since `start` on this clock. In a film that is the film's own time since it began
+/// whatever `start` was (an animation that began with the program would otherwise carry the
+/// real time the program ran before the film): two exports of one recording agree frame by frame.
pub fn since(start: Instant) -> Duration {
- now().saturating_duration_since(start)
+ match film() {
+ Some((_, t)) => t,
+ None => Instant::now().saturating_duration_since(start),
+ }
}
#[cfg(test)]
@@ -35,10 +43,14 @@ mod tests {
#[test]
fn film_time_is_the_frames_not_the_wall() {
let origin = Instant::now();
- set(Some(origin + Duration::from_millis(50)));
+ // Whatever the animation started at, a film second is a second of its animation.
+ let long_ago = origin.checked_sub(Duration::from_secs(1000)).unwrap_or(origin);
+ set(Some((origin, Duration::from_millis(50))));
assert_eq!(since(origin), Duration::from_millis(50));
+ assert_eq!(since(long_ago), Duration::from_millis(50));
std::thread::sleep(Duration::from_millis(5));
assert_eq!(since(origin), Duration::from_millis(50));
+ assert_eq!(now(), origin + Duration::from_millis(50));
set(None);
assert!(since(origin) >= Duration::from_millis(5));
}
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 3f776fd3f..7a87e9ffb 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -11584,6 +11584,8 @@ impl Game<'_> {
),
};
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
+ // AirDef fork: the position stays the player's (a mod places its sounds round it); the
+ // orientation is the main view's below, in every filming mode, preview and export alike.
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
// AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the
@@ -11598,11 +11600,10 @@ impl Game<'_> {
r.cut = true;
}
}
- if let Some(v) = &main_view
- && self.film.recording()
- {
+ if let Some(v) = &main_view {
+ let (yaw, pitch) = film::listener_orientation(Some((v.yaw, v.pitch)), (view_yaw, view_pitch));
self.audio
- .set_listener([camera.x, camera.y, camera.z], v.yaw, v.pitch);
+ .set_listener([camera.x, camera.y, camera.z], yaw, pitch);
}
let (camera, underwater, view_yaw, view_pitch) = match &main_view {
Some(v) => (v.camera, false, v.yaw, v.pitch),
@@ -11701,6 +11702,9 @@ impl Game<'_> {
let debug_lines = horizon_client::gizmos::camera_relative(&lines, at, view);
let debug_lines_on_top = horizon_client::gizmos::camera_relative(&on_top, at, view);
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
+ // AirDef fork: while a film records, the clouds' wind runs on the film's own frames, not the
+ // world's ticks (so two exports of one recording show the same sky at frame N).
+ let cloud_clock = self.film_cloud_clock();
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
@@ -11880,7 +11884,14 @@ impl Game<'_> {
.collect();
let hd_lights = self.mod_lights(camera, alpha);
let hd_spots = self.mod_spots(camera, alpha);
- let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
+ // The smoke volume is ray-marched inside the fog pass: it is drawn only where that runs (fog
+ // on, an Overworld sky, not under water). The mods are told exactly that, so a mod keeps its
+ // own smoke where HD would draw none.
+ let smoke_drawn = horizon_client::gfx::hd_smoke_possible()
+ && self.dim == Dim::Overworld
+ && self.settings.horizon.sky
+ && !underwater;
+ let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha, smoke_drawn);
// AirDef fork: HD's fog reads the ground's height round the camera.
let hd_ground = |x: i32, z: i32| {
self.world
@@ -11969,8 +11980,8 @@ impl Game<'_> {
texture: &self.pack.clouds,
uv: [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0],
camera,
- time: self.world.game_time,
- partial_tick: alpha,
+ time: cloud_clock.map_or(self.world.game_time, |c| c.0),
+ partial_tick: cloud_clock.map_or(alpha, |c| c.1),
range: self.settings.cloud_range,
fast: self.settings.clouds == "fast",
color: sky.cloud_color,
diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs
index 2ae449fc5..1535eb281 100644
--- a/crates/horizon-client/src/game/film.rs
+++ b/crates/horizon-client/src/game/film.rs
@@ -446,6 +446,40 @@ fn write_wav(raw: &Path, wav: &Path) -> Result<bool, String> {
Ok(true)
}
+/// The frames left in flight at the end, in order, each to the writer exactly once. A readback
+/// or send error is returned (the film is then not passed off as complete).
+fn send_rest(
+ rest: Result<Vec<CapturedFrame>, String>,
+ sink: Option<&mpsc::SyncSender<Msg>>,
+ sent: &mut u32,
+) -> Result<(), String> {
+ let frames = rest.map_err(|e| format!("the last frames could not be read back: {e}"))?;
+ for f in frames {
+ let Some(tx) = sink else {
+ return Err("the writer is gone: the last frames were lost".into());
+ };
+ tx.send(Msg::Frame(f))
+ .map_err(|_| "the writer stopped: the last frames were lost".to_string())?;
+ *sent += 1;
+ }
+ Ok(())
+}
+
+/// The cloud clock of frame number `stepped` of a film at `fps`: (whole game ticks, the fraction
+/// of a tick), from the film's own frame count alone.
+pub(super) fn film_cloud_time(stepped: u64, fps: u32) -> (i64, f64) {
+ let fps = u64::from(fps.max(1));
+ let ticks = stepped * 20 / fps;
+ let rest = (stepped * 20 % fps) as f64 / fps as f64;
+ (ticks as i64, rest)
+}
+
+/// The listener's orientation: the main view's (a replay or film camera) whenever there is one,
+/// else the player's own. Only the orientation: the position is the player's either way.
+pub(super) fn listener_orientation(main: Option<(f32, f32)>, own: (f32, f32)) -> (f32, f32) {
+ main.unwrap_or(own)
+}
+
impl FilmState {
/// Whether frames are being recorded now.
pub(super) fn recording(&self) -> bool {
@@ -456,6 +490,12 @@ impl FilmState {
}
impl Game<'_> {
+ /// The clouds' time for the frame being drawn while a film records, else None (the world's).
+ pub(super) fn film_cloud_clock(&self) -> Option<(i64, f64)> {
+ let film = self.film.film.as_ref().filter(|f| f.phase == Phase::Recording)?;
+ Some(film_cloud_time(film.stepped, film.req.fps))
+ }
+
/// Start of a frame: a mod's start or stop, the writer's end, and the time step to run the
/// frame by: `real` normally, exactly one frame of the film while it records.
pub(super) fn film_step(&mut self, real: Duration) -> Duration {
@@ -479,9 +519,10 @@ impl Game<'_> {
film.stepped += 1;
let at = |k: u64| (k * 1_000_000_000 + fps / 2) / fps;
// Animations on wall time follow the film's frames, not the machine's speed.
- horizon_client::film_clock::set(Some(
- film.origin + Duration::from_nanos(at(n + 1)),
- ));
+ horizon_client::film_clock::set(Some((
+ film.origin,
+ Duration::from_nanos(at(n + 1)),
+ )));
Duration::from_nanos(at(n + 1) - at(n))
}
_ => real,
@@ -523,6 +564,7 @@ impl Game<'_> {
let cancelled = film.cancel;
let before = film.before;
let audio = film.req.audio;
+ let note = film.note.clone();
self.film.film = None;
if audio {
self.audio.film_end();
@@ -533,7 +575,9 @@ impl Game<'_> {
frames,
size,
file: o.file,
- message: if cancelled && !o.ok {
+ message: if !note.is_empty() && (cancelled || !o.ok) {
+ note
+ } else if cancelled && !o.ok {
"cancelled".into()
} else {
o.message
@@ -659,7 +703,7 @@ impl Game<'_> {
film.phase = Phase::Recording;
film.stepped = 0;
film.origin = std::time::Instant::now();
- horizon_client::film_clock::set(Some(film.origin));
+ horizon_client::film_clock::set(Some((film.origin, Duration::ZERO)));
if film.req.audio {
self.audio.film_begin();
}
@@ -779,21 +823,17 @@ impl Game<'_> {
};
}
Phase::Recording => {
+ let mut cancel = cancel;
if let Some(r) = self.renderer.as_mut() {
r.capture_next = false;
- let rest = if cancel {
+ if cancel {
r.capture_discard();
- Ok(Vec::new())
} else {
- r.capture_ready(0).and_then(|_| {
- // Everything in flight, waited for.
- r.capture_ready(0)
- })
- };
- if let (Ok(frames), Some(tx)) = (rest, &film.sink) {
- for f in frames {
- film.sent += 1;
- let _ = tx.send(Msg::Frame(f));
+ // One wait for everything in flight; every frame of it goes on.
+ let rest = r.capture_ready(0).map_err(|e| e.to_string());
+ if let Err(e) = send_rest(rest, film.sink.as_ref(), &mut film.sent) {
+ film.note = e;
+ cancel = true;
}
}
}
@@ -821,3 +861,71 @@ impl Game<'_> {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frame(n: u8) -> CapturedFrame {
+ CapturedFrame {
+ size: [1, 1],
+ bgra: true,
+ data: vec![n, 0, 0, 255],
+ }
+ }
+
+ fn drain(rx: &mpsc::Receiver<Msg>) -> Vec<u8> {
+ rx.try_iter()
+ .filter_map(|m| match m {
+ Msg::Frame(f) => Some(f.data[0]),
+ _ => None,
+ })
+ .collect()
+ }
+
+ /// #79: whatever is still in flight at the stop (0, 1 or 2 frames, ready or not) reaches the
+ /// writer once, in order; none is thrown away.
+ #[test]
+ fn the_frames_in_flight_at_the_stop_all_reach_the_writer() {
+ for pending in 0..=3u8 {
+ let (tx, rx) = mpsc::sync_channel(8);
+ let mut sent = 7;
+ let rest = (0..pending).map(|i| frame(10 + i)).collect();
+ send_rest(Ok(rest), Some(&tx), &mut sent).unwrap();
+ assert_eq!(sent, 7 + u32::from(pending));
+ assert_eq!(drain(&rx), (0..pending).map(|i| 10 + i).collect::<Vec<_>>());
+ }
+ }
+
+ /// #84: the main view's orientation is used with or without a capture.
+ #[test]
+ fn the_listener_follows_the_main_view_in_every_filming_mode() {
+ assert_eq!(listener_orientation(Some((90.0, 5.0)), (0.0, 0.0)), (90.0, 5.0));
+ assert_eq!(listener_orientation(None, (12.0, -3.0)), (12.0, -3.0));
+ }
+
+ /// #80: the cloud wind of frame N depends on N and the frame rate only.
+ #[test]
+ fn the_cloud_clock_is_the_films_frames() {
+ // One second of film is 20 ticks at any frame rate; the frame in between is a fraction.
+ for fps in [24u32, 30, 60] {
+ assert_eq!(film_cloud_time(u64::from(fps), fps), (20, 0.0));
+ assert_eq!(film_cloud_time(u64::from(fps) * 5, fps).0, 100);
+ }
+ let (t, p) = film_cloud_time(1, 60);
+ assert_eq!(t, 0);
+ assert!((p - 20.0 / 60.0).abs() < 1e-12);
+ // Pure: the same frame, the same sky.
+ assert_eq!(film_cloud_time(777, 30), film_cloud_time(777, 30));
+ }
+
+ #[test]
+ fn a_failed_readback_or_dead_writer_is_an_error_not_a_short_success() {
+ let (tx, rx) = mpsc::sync_channel(8);
+ let mut sent = 0;
+ assert!(send_rest(Err("gpu".into()), Some(&tx), &mut sent).is_err());
+ drop(rx);
+ assert!(send_rest(Ok(vec![frame(1)]), Some(&tx), &mut sent).is_err());
+ assert_eq!(sent, 0);
+ }
+}
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index 78ee8448b..283acaa8a 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -96,6 +96,7 @@ impl Game<'_> {
&mut self,
_camera: glam::DVec3,
_alpha: f64,
+ _drawn: bool,
) -> (Vec<horizon_client::gfx::HdSmoke>, f32) {
(Vec::new(), 0.0)
}
@@ -310,8 +311,11 @@ impl Game<'_> {
&mut self,
camera: DVec3,
alpha: f64,
+ drawn: bool,
) -> (Vec<horizon_client::gfx::HdSmoke>, f32) {
- let on = self.mod_hd().is_some() && horizon_client::gfx::hd_smoke_enabled();
+ // What the mod is told is what is drawn: HD on, the smoke pass possible, and this frame's
+ // fog pass (an Overworld sky, above water) running.
+ let on = self.mod_hd().is_some() && drawn;
self.mods.mods.set_smoke_active(on);
let Some((before, now)) = self.mods.mods.smoke_clock().filter(|_| on) else {
return (Vec::new(), 0.0);
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index a2a6e78b6..06fa09705 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -87,7 +87,7 @@ mod native_post;
pub use hd::set_film_quality;
pub use hd::{
HdLight, HdSettings, HdSmoke, HdSpot, MAX_SPOTS as HD_MAX_SPOTS,
- smoke_enabled as hd_smoke_enabled, spots_enabled as hd_spots_enabled,
+ smoke_enabled as hd_smoke_enabled, smoke_possible as hd_smoke_possible, spots_enabled as hd_spots_enabled,
};
pub use mod_views::ModView;
mod native_sky;
diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs
index d8c015a28..7744784d5 100644
--- a/crates/horizon-client/src/gfx/hd/mod.rs
+++ b/crates/horizon-client/src/gfx/hd/mod.rs
@@ -97,7 +97,7 @@ pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, light_of, vanil
pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL};
pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds};
pub use shadows::{CASCADES, SHADOW_TINT, cascades, color_shadows_enabled};
-pub use smoke::{HdSmoke, smoke_enabled};
+pub use smoke::{HdSmoke, smoke_enabled, smoke_possible};
pub use taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled};
pub use volumes::clouds_enabled;
@@ -746,7 +746,7 @@ impl Hd {
lights: &[HdLight],
clock: f32,
) {
- if scene.sky && volumes::fog_enabled() && smoke_enabled() {
+ if scene.sky && smoke::smoke_possible() {
self.smoke.prepare(queue, scene, smoke, lights, clock);
} else {
self.smoke.frame = Default::default();
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
index 266f5fdef..34f3fd77c 100644
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
@@ -64,6 +64,30 @@ pub fn smoke_enabled() -> bool {
*ON.get_or_init(|| std::env::var("HORIZON_HD_SMOKE").map_or(true, |v| v != "0"))
}
+/// Whether the smoke can be drawn at all: its volume is ray-marched in the fog pass, so it needs
+/// the volumetric fog as well as `HORIZON_HD_SMOKE`. This is what the mods are told.
+pub fn smoke_possible() -> bool {
+ smoke_possible_with(super::volumes::fog_enabled(), smoke_enabled())
+}
+
+fn smoke_possible_with(fog: bool, smoke: bool) -> bool {
+ fog && smoke
+}
+
+#[cfg(test)]
+mod possible_tests {
+ use super::smoke_possible_with;
+
+ /// #81: the answer to the mods is the real one: no fog pass, no smoke promised.
+ #[test]
+ fn smoke_is_promised_only_with_the_fog_pass() {
+ assert!(smoke_possible_with(true, true));
+ assert!(!smoke_possible_with(false, true));
+ assert!(!smoke_possible_with(true, false));
+ assert!(!smoke_possible_with(false, false));
+ }
+}
+
/// A segment of smoke as the game hands it over for a frame, relative to the camera.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 19:07:37 +0300
Subject: [PATCH 38/38] HD clouds: rays from a far point, so the view bob's eye
offset does not bend them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd/cloud.wgsl | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
index 18aac24f6..abe69df78 100644
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
@@ -233,7 +233,8 @@ fn fs_cloud(in: FullOut) -> CloudOut {
let sndc = vec4<f32>(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0);
let sh = frame.inverse_view_proj * sndc;
let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0);
- let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
+ // A far point: the view bob offsets the eye, and from a point 10 cm away (depth 0.5) that bends the ray.
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.0005, 1.0);
let hh = frame.inverse_view_proj * ndc;
let dir = normalize(hh.xyz / hh.w);
let m = march_cumulus(dir, scene_len, ign(in.position.xy), false);
--
2.54.0 (Apple Git-157)
+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")
+384 -15
View File
@@ -8,6 +8,7 @@
Запуск: python3 tools/gen_models.py
"""
import json
import math
import os
import sys
import random
@@ -1192,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, "Винт"),
@@ -1250,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, "Голова прожектора"),
@@ -1301,20 +1335,355 @@ ITEMS = [
]
def item_icon(color, seed):
rnd = random.Random(seed)
im = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
for y in range(2, 14):
for x in range(4, 12):
im.putpixel((x, y), tuple(clamp(c + rnd.randint(-10, 10)) for c in color) + (255,))
for x in range(5, 11):
im.putpixel((x, 3), (230, 230, 230, 255))
im.putpixel((7, 6), (255, 60, 60, 255))
im.putpixel((8, 6), (255, 60, 60, 255))
for y in range(13, 16):
im.putpixel((7, y), (40, 40, 40, 255))
im.putpixel((8, y), (40, 40, 40, 255))
return im
# Иконки 16×16 без сглаживания. Каждый предмет рисуется своей функцией: категория задаёт основу
# (машина ПВО, пуск с пламенем, РЛС, склад, прожектор, целеуказатель, стрелки, сворачивание),
# внутри категории — мини-силуэт типа. Цвет предмета — только заливка, форма различает предметы.
class _Icon:
def __init__(self, color, seed):
self.rnd = random.Random(seed)
self.im = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
self.pal = {
"b": tuple(color),
"d": tuple(clamp(v * 0.6) for v in color),
"l": tuple(clamp(v + 45) for v in color),
"k": (30, 30, 34), "r": (255, 60, 60), "o": (255, 150, 40),
"y": (255, 232, 120), "w": (236, 236, 236),
}
def px(self, x, y, key):
if 0 <= x < 16 and 0 <= y < 16:
c = self.pal[key]
if key in ("b", "d", "l"):
c = tuple(clamp(v + self.rnd.randint(-6, 6)) for v in c)
self.im.putpixel((x, y), c + (255,))
def rect(self, x0, y0, x1, y1, key):
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
self.px(x, y, key)
def line(self, x0, y0, x1, y1, key):
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
err = dx + dy
while True:
self.px(x0, y0, key)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def disc(self, cx, cy, r, key):
for y in range(cy - r - 1, cy + r + 2):
for x in range(cx - r - 1, cx + r + 2):
if (x - cx) ** 2 + (y - cy) ** 2 <= r * r + 0.5 * r:
self.px(x, y, key)
def ring(self, cx, cy, lo, hi, key):
for y in range(16):
for x in range(16):
if lo <= (x - cx) ** 2 + (y - cy) ** 2 <= hi:
self.px(x, y, key)
def poly(self, pts, key):
# Выпуклый многоугольник: заливка по строкам между крайними пересечениями рёбер.
n = len(pts)
for y in range(min(p[1] for p in pts), max(p[1] for p in pts) + 1):
xs = []
for i in range(n):
(x1, y1), (x2, y2) = pts[i], pts[(i + 1) % n]
if y1 == y2:
if y1 == y:
xs += [x1, x2]
elif min(y1, y2) <= y <= max(y1, y2):
xs.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1))
if xs:
for x in range(math.ceil(min(xs) - 1e-9), math.floor(max(xs) + 1e-9) + 1):
self.px(x, y, key)
def _sam_base(c):
c.rect(2, 8, 13, 11, "b")
c.rect(3, 12, 4, 13, "k")
c.rect(11, 12, 12, 13, "k")
# Развёртывание ПВО: машина + пусковая установка.
def _gepard(c): # ствольная турель, два длинных ствола
_sam_base(c)
c.disc(7, 6, 2, "b")
c.line(9, 5, 15, 5, "d")
c.line(9, 7, 15, 7, "d")
def _pantsir(c): # ствольная турель с ракетными контейнерами сверху
_sam_base(c)
c.disc(7, 7, 2, "b")
c.rect(4, 3, 5, 5, "l")
c.rect(9, 3, 10, 5, "l")
c.line(9, 6, 11, 6, "d")
def _s400(c): # три вертикальных контейнера
_sam_base(c)
for x in (3, 6, 9):
c.rect(x, 2, x + 1, 7, "b")
c.rect(x, 2, x + 1, 2, "l")
def _s500(c): # четыре вертикальных контейнера
_sam_base(c)
for x in (2, 5, 8, 11):
c.rect(x, 2, x + 1, 7, "b")
c.rect(x, 2, x + 1, 2, "l")
def _patriot(c): # наклонный пусковой короб
_sam_base(c)
c.poly([(3, 8), (11, 3), (13, 5), (5, 10)], "b")
c.line(7, 7, 9, 5, "d")
def _buk(c): # наклонные направляющие с ракетой
_sam_base(c)
c.line(3, 8, 10, 2, "d")
c.line(5, 9, 12, 3, "d")
c.disc(12, 2, 1, "w")
def _irist(c): # два наклонных контейнера
_sam_base(c)
c.poly([(3, 8), (7, 3), (9, 4), (5, 9)], "b")
c.poly([(7, 9), (11, 3), (13, 4), (9, 10)], "b")
def _nasams(c): # два горизонтальных контейнера
_sam_base(c)
c.rect(3, 2, 12, 3, "b")
c.rect(3, 5, 12, 6, "b")
c.line(3, 4, 12, 4, "d")
def _tor(c): # сетка коротких вертикальных ячеек
_sam_base(c)
for x in (3, 6, 9):
c.rect(x, 2, x + 1, 3, "b")
c.rect(x, 5, x + 1, 6, "b")
def _p1sun(c): # короб с мачтой
_sam_base(c)
c.rect(3, 5, 12, 7, "b")
c.line(7, 1, 7, 4, "d")
c.px(7, 1, "r")
# Радиолокация и РЭБ.
def _radar(c): # параболическая антенна на стойке
c.disc(8, 5, 3, "l")
c.line(8, 8, 8, 12, "d")
c.rect(5, 13, 11, 14, "d")
def _ew(c): # штырь-антенна с сигнальными дугами
c.line(8, 6, 8, 14, "d")
c.disc(8, 4, 1, "b")
c.line(5, 2, 4, 4, "l")
c.line(4, 4, 4, 6, "l")
c.line(4, 6, 5, 8, "l")
c.line(11, 2, 12, 4, "l")
c.line(12, 4, 12, 6, "l")
c.line(12, 6, 11, 8, "l")
# Снабжение.
def _depot(c): # штабель ящиков на поддоне
c.rect(3, 8, 12, 13, "b")
c.line(3, 10, 12, 10, "d")
c.line(3, 12, 12, 12, "d")
c.rect(2, 14, 13, 14, "d")
c.rect(4, 4, 9, 7, "l")
c.line(4, 6, 9, 6, "d")
def _tzm(c): # грузовик с кабиной и кузовом
c.rect(2, 7, 5, 11, "b")
c.rect(3, 8, 4, 9, "w")
c.rect(6, 4, 14, 11, "l")
c.rect(3, 12, 4, 13, "k")
c.rect(10, 12, 11, 13, "k")
c.rect(13, 12, 14, 13, "k")
# Прожектор.
def _searchlight(c):
c.rect(3, 4, 10, 8, "b")
c.poly([(10, 4), (15, 1), (15, 11), (10, 8)], "y")
c.line(6, 9, 6, 13, "d")
c.rect(3, 14, 9, 14, "d")
# Целеуказатель: кольцо с засечками.
def _designator(c):
c.ring(8, 8, 16, 26, "r")
c.rect(7, 1, 8, 2, "r")
c.rect(7, 13, 8, 14, "r")
c.rect(1, 7, 2, 8, "r")
c.rect(13, 7, 14, 8, "r")
c.rect(7, 7, 8, 8, "r")
# Пуски: огонь сзади, силуэт типа спереди.
def _launch_base(c):
c.rect(0, 7, 1, 8, "o")
c.px(0, 7, "y")
def _shahed(c): # треугольное крыло
_launch_base(c)
c.poly([(5, 2), (5, 13), (13, 7)], "b")
def _geran3(c): # реактивный самолёт со стреловидным крылом
_launch_base(c)
c.rect(3, 7, 13, 8, "b")
c.poly([(6, 7), (9, 2), (11, 2), (9, 7)], "d")
c.poly([(6, 8), (9, 13), (11, 13), (9, 8)], "d")
c.poly([(3, 7), (4, 3), (5, 3), (5, 7)], "d")
def _lancet(c): # малый дрон с прямым крылом
_launch_base(c)
c.rect(3, 7, 12, 8, "b")
c.rect(7, 2, 8, 13, "d")
c.rect(3, 6, 4, 9, "d")
def _kh101(c): # крылатая ракета: стреловидные крылья и хвост
_launch_base(c)
c.rect(3, 7, 12, 8, "b")
c.poly([(12, 6), (15, 7), (15, 8), (12, 9)], "b")
c.poly([(6, 7), (9, 3), (10, 3), (8, 7)], "d")
c.poly([(6, 8), (9, 12), (10, 12), (8, 8)], "d")
c.rect(2, 5, 3, 10, "d")
def _kalibr(c): # крылатая ракета: тонкий корпус и четыре хвостовых киля
_launch_base(c)
c.rect(2, 7, 13, 8, "b")
c.poly([(13, 6), (15, 7), (15, 8), (13, 9)], "b")
c.rect(2, 4, 3, 5, "d")
c.rect(2, 10, 3, 11, "d")
def _kh22(c): # толстый корпус с большими дельта-крыльями
_launch_base(c)
c.rect(3, 6, 11, 9, "b")
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "b")
c.poly([(4, 6), (8, 2), (9, 2), (7, 6)], "d")
c.poly([(4, 9), (8, 13), (9, 13), (7, 9)], "d")
def _zircon(c): # гиперзвук: корпус с одним передним стрейком
_launch_base(c)
c.rect(2, 7, 12, 8, "b")
c.poly([(12, 7), (15, 7), (12, 8)], "b")
c.poly([(8, 7), (12, 3), (13, 3), (11, 7)], "d")
def _iskander(c): # баллистическая: длинный корпус, конус, сопло
_launch_base(c)
c.rect(3, 7, 11, 8, "b")
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "l")
c.rect(2, 6, 2, 9, "d")
def _oreshnik(c): # баллистическая с тремя головными частями
_launch_base(c)
c.rect(2, 6, 10, 9, "b")
c.poly([(10, 5), (13, 6), (10, 7)], "l")
c.poly([(10, 8), (13, 9), (10, 10)], "l")
c.poly([(10, 6), (15, 7), (10, 8)], "b")
def _kinzhal(c): # аэробаллистическая: корпус с треугольными килями
_launch_base(c)
c.rect(3, 7, 11, 8, "b")
c.poly([(11, 6), (15, 7), (11, 9)], "b")
c.poly([(4, 7), (7, 3), (8, 3), (6, 7)], "d")
c.poly([(4, 8), (7, 12), (8, 12), (6, 8)], "d")
def _mover(c): # четыре стрелки перемещения
c.poly([(8, 0), (4, 5), (12, 5)], "b")
c.rect(7, 4, 8, 7, "b")
c.poly([(8, 15), (4, 10), (12, 10)], "b")
c.rect(7, 8, 8, 11, "b")
c.poly([(0, 8), (5, 4), (5, 12)], "b")
c.rect(4, 7, 7, 8, "b")
c.poly([(15, 8), (10, 4), (10, 12)], "b")
c.rect(8, 7, 11, 8, "b")
def _remover(c): # стрелка вниз над лотком: свёртывание
c.poly([(8, 9), (3, 4), (13, 4)], "b")
c.rect(7, 1, 8, 4, "b")
c.rect(2, 11, 13, 13, "b")
c.line(2, 11, 13, 11, "l")
def _flare(c): # звезда осветительной ракеты на стержне
c.disc(8, 7, 1, "y")
c.line(8, 2, 8, 4, "y")
c.line(8, 10, 8, 12, "y")
c.line(3, 7, 5, 7, "y")
c.line(11, 7, 13, 7, "y")
c.line(4, 4, 5, 5, "w")
c.line(11, 4, 10, 5, "w")
c.line(4, 10, 5, 9, "w")
c.line(11, 10, 10, 9, "w")
c.rect(7, 12, 8, 15, "k")
ICON_DRAW = {
"designator": _designator,
"launch_shahed": _shahed, "launch_geran3": _geran3, "launch_lancet": _lancet,
"launch_kh101": _kh101, "launch_kalibr": _kalibr, "launch_kh22": _kh22,
"launch_zircon": _zircon, "launch_iskander": _iskander, "launch_oreshnik": _oreshnik,
"launch_kinzhal": _kinzhal,
"deploy_patriot": _patriot, "deploy_gepard": _gepard, "deploy_pantsir": _pantsir,
"deploy_s400": _s400, "deploy_s500": _s500, "deploy_irist": _irist,
"deploy_nasams": _nasams, "deploy_buk": _buk, "deploy_tor": _tor, "deploy_p1sun": _p1sun,
"deploy_radar": _radar, "deploy_ew": _ew,
"deploy_depot": _depot, "deploy_tzm": _tzm,
"deploy_light": _searchlight,
"mover": _mover, "remover": _remover, "flare_gun": _flare,
}
def item_icon(name, color, seed):
c = _Icon(color, seed)
ICON_DRAW[name](c)
# Контур по краю силуэта: тёмные предметы — светлым, светлые — тёмным, чтобы читались на обоих фонах.
# Альфа-маска не меняется: контур перекрашивает только уже нарисованные пиксели.
lum = 0.3 * color[0] + 0.59 * color[1] + 0.11 * color[2]
edge = (160, 160, 160) if lum < 100 else (70, 70, 70) if lum > 190 else None
if edge:
src = c.im.copy()
def opaque(x, y):
return 0 <= x < 16 and 0 <= y < 16 and src.getpixel((x, y))[3] > 0
for y in range(16):
for x in range(16):
if opaque(x, y) and not all(opaque(x + dx, y + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))):
c.im.putpixel((x, y), edge + (255,))
return c.im
def write_json(path, obj):
@@ -1350,7 +1719,7 @@ def main():
im = GT.burn(im, zlib.crc32(name.encode()), "glass" if base.startswith("glass") else "paint")
im.save(os.path.join(a, "textures", "block", name + ".png"))
for i, (name, title, color) in enumerate(ITEMS):
item_icon(color, i).save(os.path.join(a, "textures", "item", name + ".png"))
item_icon(name, color, zlib.crc32(name.encode())).save(os.path.join(a, "textures", "item", name + ".png"))
write_json(os.path.join(a, "models", "item", name + ".json"),
{"parent": "minecraft:item/generated", "textures": {"layer0": f"{NS}:item/{name}"}})
write_json(os.path.join(a, "items", name + ".json"),
+36 -6
View File
@@ -20,7 +20,11 @@
Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между
кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не
участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям:
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Сильное мерцание
по кадрам одного прогона от движения не отличить, поэтому «движение» само ограничено: доля непроверенных
пикселей (--max-animated), сумма «неустойчивых» (--max-flicker), пиксели, ставшие движущимися против
БАЗЫ (--max-new-animated: маска берётся из базы, не из проверяемого прогона), и доля проверяемых
пикселей (--min-static: ноль статичных — не «ноль ошибки», а провал). Для движущейся части
выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску.
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
@@ -337,19 +341,31 @@ def motion_range(rgbs):
def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level):
"""По размаху каждого пикселя между кадрами одного прогона. `pct` — мерцание: размах в
(flicker_level, motion_level]; `animated_pct` — выше motion_level (движение: вода, облака,
листва; ИЛИ очень сильное мерцание — по одному прогону их не различить). `unsteady_pct` — сумма
обоих: все пиксели с размахом выше flicker_level, она не падает, когда мерцание усиливается и
переходит из одной корзины в другую. `unverified_pct` — доля пикселей, которые этот прогон
ничем не проверяет (то же, что animated_pct); `static_pct` — доля проверяемых."""
npx = w * h
flick = sum(1 for r in rng if flicker_level < r <= motion_level)
moving = sum(1 for r in rng if r > motion_level)
return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx,
"unsteady_pct": 100.0 * (flick + moving) / npx,
"unverified_pct": 100.0 * moving / npx, "static_pct": 100.0 * (npx - moving) / npx,
"frames": n_frames, "size": [w, h]}
def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None):
"""Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух
прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная
средняя разница (информация, не порог)."""
"""Первый кадр текущего прогона против первого кадра базы. Из сравнения исключаются только
пиксели, которые двигались уже в БАЗЕ (маска воспроизводимая, не зависит от того, что показал
проверяемый прогон): mean и pct — по остальным; для движущихся — отдельная средняя разница
(информация, не порог). Пиксель, ставший «движущимся» только в текущем прогоне (мерцание,
призраки TAA), из сравнения не исключается и считается в new_animated_pct. Доля проверяемых
пикселей — static_pct: при нуле mean не «0 ошибки», а отсутствие проверки (см. judge)."""
npx = w * h
static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng))
static = bytes(1 if b <= motion_level else 0 for b in base_rng)
new_anim = sum(1 for a, b in zip(cur_rng, base_rng) if a > motion_level and b <= motion_level)
d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0])))
dpix = _per_pixel_max(d)
n_static = sum(static)
@@ -358,6 +374,7 @@ def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_
over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL)
moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving)
if n_moving else 0.0)
cur_anim = sum(1 for a in cur_rng if a > motion_level)
if diff_out:
img = bytearray(min(255, v * DIFF_GAIN) for v in d)
for i, s in enumerate(static):
@@ -368,7 +385,9 @@ def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_
if motion_out:
write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static))))
return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx,
"animated_mean": moving_mean, "size": [w, h]}
"animated_mean": moving_mean, "static_pct": 100.0 * n_static / npx,
"new_animated_pct": 100.0 * new_anim / npx, "cur_animated_pct": 100.0 * cur_anim / npx,
"size": [w, h]}
# --- тайминги -----------------------------------------------------------------------------------
@@ -482,11 +501,17 @@ def judge(entry, args):
fl = entry.get("flicker")
if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker:
reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
if fl and fl["animated_pct"] > args.max_animated:
reasons.append(f"не проверено (размах > {args.motion_level}/255) {fl['animated_pct']:.1f}% кадра > {args.max_animated:g}%")
base = entry.get("vs_baseline")
if base:
if base.get("error"):
reasons.append(base["error"])
else:
if base["static_pct"] < args.min_static:
reasons.append(f"проверяемых пикселей {base['static_pct']:.1f}% < {args.min_static:g}%: сравнение с базой ничего не доказывает")
if base["new_animated_pct"] > args.max_new_animated:
reasons.append(f"новых движущихся пикселей против базы {base['new_animated_pct']:.2f}% > {args.max_new_animated:g}%")
if base["mean"] > args.max_mean:
reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}")
if base["pct"] > args.max_pct:
@@ -522,6 +547,8 @@ def write_report(out, entries, args, env_extra, size, baseline, meta):
fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-"
mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-"
b = e.get("vs_baseline") or {}
if "new_animated_pct" in b: # what the baseline comparison covers and what grew
mv += f" (проверено {b['static_pct']:.0f}%, новых {b['new_animated_pct']:.2f}%)"
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
@@ -584,6 +611,9 @@ def parse_args(argv):
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255")
p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%")
p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)")
p.add_argument("--max-animated", type=float, default=60.0, help="порог доли кадра, которую прогон не проверяет (размах выше --motion-level), %%; не откалиброван")
p.add_argument("--max-new-animated", type=float, default=2.0, help="порог пикселей, статичных в базе и движущихся в прогоне, %%; не откалиброван")
p.add_argument("--min-static", type=float, default=10.0, help="наименьшая доля проверяемых (статичных в базе) пикселей, %%")
p.add_argument("--no-freeze", action="store_true",
help="не замораживать игровое время и погоду (живой замер производительности, не снимки)")
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
+89
View File
@@ -0,0 +1,89 @@
"""Tests of tools/hd_bench.py's comparison (#82): `python3 -m unittest tools/test_hd_bench.py`."""
import argparse
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import hd_bench as hb # noqa: E402
def frame(w, h, v):
return bytes([v]) * (3 * w * h)
def args(**kw):
a = argparse.Namespace(budget_ms=18.0, max_flicker=1.0, max_animated=60.0, max_mean=3.0,
max_pct=5.0, max_new_animated=2.0, min_static=10.0, motion_level=hb.MOTION_LEVEL)
a.__dict__.update(kw)
return a
def judged(rgbs, base_rgbs, w=2, h=2):
rng = hb.motion_range(rgbs)
brng = hb.motion_range(base_rgbs)
entry = {"gpu_avg_ms": 1.0}
entry["flicker"] = hb.flicker_stats(rng, w, h, len(rgbs), hb.MOTION_LEVEL, hb.FLICKER_LEVEL)
entry["vs_baseline"] = hb.compare_runs(rgbs, rng, base_rgbs, brng, w, h, hb.MOTION_LEVEL)
return entry, hb.judge(entry, args())
class FlickerIsNotAnimation(unittest.TestCase):
def test_the_issues_black_white_strobe_fails(self):
# The issue: a 2x2 frame, the baseline steadily black, the run alternating black and white.
entry, reasons = judged([frame(2, 2, 0), frame(2, 2, 255)] * 2, [frame(2, 2, 0)] * 3)
self.assertEqual(entry["flicker"]["pct"], 0.0) # the old blind spot is still in these two numbers ...
self.assertEqual(entry["flicker"]["animated_pct"], 100.0)
self.assertEqual(entry["flicker"]["unsteady_pct"], 100.0)
self.assertEqual(entry["vs_baseline"]["new_animated_pct"], 100.0)
self.assertTrue(reasons, "... but the verdict is a failure")
def test_stronger_flicker_never_starts_passing(self):
w = h = 4
base = [frame(w, h, 100)] * 3
failed = []
unsteady = []
for amp in (8, 16, 32, 255):
lo, hi = 100 - min(amp // 2, 100), min(255, 100 + (amp + 1) // 2)
run = [frame(w, h, lo), frame(w, h, hi), frame(w, h, lo)]
entry, reasons = judged(run, base, w, h)
failed.append(bool(reasons))
unsteady.append(entry["flicker"]["unsteady_pct"])
self.assertEqual(failed, [True] * 4, failed)
self.assertEqual(unsteady, sorted(unsteady))
def test_a_fully_dynamic_frame_is_not_a_zero_error(self):
w = h = 2
moving = [frame(w, h, 0), frame(w, h, 255)]
entry, reasons = judged(moving, moving)
self.assertEqual(entry["vs_baseline"]["static_pct"], 0.0)
self.assertTrue(any("проверяемых" in r for r in reasons), reasons)
def test_water_and_foliage_that_also_move_in_the_baseline_pass(self):
w, h = 10, 10
n = w * h
def mixed(a, b): # the left 20% animates (water), the rest is still
f = bytearray(frame(w, h, 90))
for px in range(n // 5):
f[3 * px:3 * px + 3] = bytes([a]) * 3
return bytes(f)
run = [mixed(40, 40), mixed(120, 40), mixed(60, 40)]
base = [mixed(30, 40), mixed(130, 40), mixed(70, 40)]
entry, reasons = judged(run, base, w, h)
self.assertEqual(entry["vs_baseline"]["new_animated_pct"], 0.0)
self.assertEqual(reasons, [])
def test_the_mask_comes_from_the_baseline_not_from_the_run(self):
# Growth of the animated area against the baseline is counted and bounded.
w = h = 10
base = [frame(w, h, 90)] * 3
f = bytearray(frame(w, h, 90))
f[0:30] = bytes([250]) * 30 # 10% of the pixels
run = [bytes(f), frame(w, h, 90), bytes(f)]
entry, reasons = judged(run, base, w, h)
self.assertAlmostEqual(entry["vs_baseline"]["new_animated_pct"], 10.0)
self.assertTrue(reasons)
if __name__ == "__main__":
unittest.main()