Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab6b61db59 | |||
| 4d4e1682f5 | |||
| 545d4777e8 | |||
| f16bc2387f | |||
| 9bc94fa8aa | |||
| 0f5c4136f3 | |||
| 51f9609327 | |||
| 3265eb63d2 | |||
| b43d471706 |
@@ -4,3 +4,4 @@
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.hmod binary
|
||||
*.patch -text
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "Голова прожектора",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 265 B |
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 336 B |
|
Before Width: | Height: | Size: 400 B After Width: | Height: | Size: 241 B |
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 278 B |
|
Before Width: | Height: | Size: 400 B After Width: | Height: | Size: 297 B |
|
Before Width: | Height: | Size: 388 B After Width: | Height: | Size: 249 B |
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 297 B |
|
Before Width: | Height: | Size: 386 B After Width: | Height: | Size: 286 B |
|
Before Width: | Height: | Size: 402 B After Width: | Height: | Size: 307 B |
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 285 B |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 399 B After Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 409 B |
|
Before Width: | Height: | Size: 401 B After Width: | Height: | Size: 338 B |
|
Before Width: | Height: | Size: 400 B After Width: | Height: | Size: 316 B |
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 154 B |
|
Before Width: | Height: | Size: 386 B After Width: | Height: | Size: 139 B |
|
Before Width: | Height: | Size: 393 B After Width: | Height: | Size: 223 B |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 210 B |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 233 B |
|
Before Width: | Height: | Size: 408 B After Width: | Height: | Size: 184 B |
|
Before Width: | Height: | Size: 409 B After Width: | Height: | Size: 254 B |
|
Before Width: | Height: | Size: 393 B After Width: | Height: | Size: 151 B |
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 149 B |
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 269 B |
|
Before Width: | Height: | Size: 408 B After Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 394 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 464 B |
|
Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 257 B |
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! The text of a saved layout (the autosave, the body of a scenario) read and checked as a whole before
|
||||
//! anything of the scene is touched (#86). `Layout::parse` is pure: it either returns every record with its
|
||||
//! values proven (finite, in range, within the limits) or the line and field that are wrong; `AirDef` swaps the
|
||||
//! scene for it only after that. Lines it doesn't know are skipped, so older and newer files read alike.
|
||||
use super::*;
|
||||
|
||||
/// Most systems and most objects one file may hold.
|
||||
pub const MAX_UNITS: usize = 2000;
|
||||
/// Largest coordinate: the world's border and then some.
|
||||
const COORD: f64 = 3.0e7;
|
||||
/// Largest count (missiles, bursts, cargo, reload ticks).
|
||||
const COUNT: f64 = 1.0e6;
|
||||
|
||||
pub struct SamRec {
|
||||
pub kind: SamKind,
|
||||
pub pos: DVec3,
|
||||
pub heading: f64,
|
||||
pub roles: Option<u8>,
|
||||
pub health: Option<f64>,
|
||||
pub cargo: Option<u32>,
|
||||
/// Missiles, bursts, empty, reloading: older saves have no ammunition (full).
|
||||
pub ammo: Option<(u32, u32, bool, u32)>,
|
||||
pub dest: Option<DVec3>,
|
||||
/// The parts, from the sensor on; older saves have fewer or none.
|
||||
pub comps: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Layout {
|
||||
pub sams: Vec<SamRec>,
|
||||
pub targets: Vec<(DVec3, f64)>,
|
||||
pub damage: Option<bool>,
|
||||
pub seed: Option<u64>,
|
||||
pub wave: Option<Vec<String>>,
|
||||
pub far: Option<bool>,
|
||||
pub effects: Option<(bool, bool)>,
|
||||
pub roles: Vec<(SamKind, u8)>,
|
||||
}
|
||||
|
||||
/// One line's words, with the line number for the messages.
|
||||
struct Line<'a> {
|
||||
n: usize,
|
||||
word: &'a str,
|
||||
w: Vec<&'a str>,
|
||||
}
|
||||
|
||||
impl Line<'_> {
|
||||
fn bad(&self, field: &str, why: &str) -> String {
|
||||
format!("строка {}: «{}», поле {field}: {why}", self.n, self.word)
|
||||
}
|
||||
/// A finite number in `lo..=hi`; `None` where the field is absent.
|
||||
fn num(&self, i: usize, field: &str, lo: f64, hi: f64) -> Result<Option<f64>, String> {
|
||||
let Some(v) = self.w.get(i) else { return Ok(None) };
|
||||
let x: f64 = v.parse().map_err(|_| self.bad(field, &format!("«{v}» не число")))?;
|
||||
if !x.is_finite() {
|
||||
return Err(self.bad(field, &format!("«{v}» не конечное число")));
|
||||
}
|
||||
if x < lo || x > hi {
|
||||
return Err(self.bad(field, &format!("{x} вне допустимого {lo}..{hi}")));
|
||||
}
|
||||
Ok(Some(x))
|
||||
}
|
||||
fn need(&self, i: usize, field: &str, lo: f64, hi: f64) -> Result<f64, String> {
|
||||
self.num(i, field, lo, hi)?.ok_or_else(|| self.bad(field, "строка оборвана"))
|
||||
}
|
||||
fn flag(&self, i: usize, field: &str) -> Result<Option<bool>, String> {
|
||||
match self.w.get(i) {
|
||||
None => Ok(None),
|
||||
Some(&"true") => Ok(Some(true)),
|
||||
Some(&"false") => Ok(Some(false)),
|
||||
Some(v) => Err(self.bad(field, &format!("«{v}»: нужно true или false"))),
|
||||
}
|
||||
}
|
||||
fn kind(&self, i: usize) -> Result<SamKind, String> {
|
||||
let v = self.w.get(i).ok_or_else(|| self.bad("тип", "строка оборвана"))?;
|
||||
SamKind::from_key(v).ok_or_else(|| self.bad("тип", &format!("неизвестный тип «{v}»")))
|
||||
}
|
||||
fn xyz(&self, i: usize, field: &str) -> Result<DVec3, String> {
|
||||
Ok(DVec3::new(self.need(i, field, -COORD, COORD)?, self.need(i + 1, field, -COORD, COORD)?, self.need(i + 2, field, -COORD, COORD)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn parse(text: &str) -> Result<Layout, String> {
|
||||
let mut out = Layout::default();
|
||||
for (n, raw) in text.lines().enumerate() {
|
||||
let w: Vec<&str> = raw.split_whitespace().collect();
|
||||
let Some(&word) = w.first() else { continue };
|
||||
let l = Line { n: n + 1, word, w };
|
||||
match word {
|
||||
"sam" => {
|
||||
if out.sams.len() >= MAX_UNITS {
|
||||
return Err(l.bad("число комплексов", &format!("больше {MAX_UNITS}")));
|
||||
}
|
||||
let kind = l.kind(1)?;
|
||||
let pos = l.xyz(2, "координаты")?;
|
||||
let heading = l.need(5, "курс", -1.0e6, 1.0e6)?;
|
||||
let roles = l.num(6, "роли", 0.0, 255.0)?.map(|r| r as u8);
|
||||
let health = l.num(7, "прочность", 0.0, 1.0)?;
|
||||
let cargo = l.num(8, "груз", 0.0, COUNT)?.map(|c| c as u32);
|
||||
let ammo = match (l.num(9, "ракеты", 0.0, COUNT)?, l.num(10, "очереди", 0.0, COUNT)?) {
|
||||
(Some(m), Some(b)) => Some((m as u32, b as u32, l.num(11, "пуст", 0.0, 1.0)? == Some(1.0), l.num(12, "перезарядка", 0.0, COUNT)?.map_or(0, |r| r as u32))),
|
||||
_ => None,
|
||||
};
|
||||
// "- - -": no destination.
|
||||
let dest = if l.w.get(13) == Some(&"-") { None } else if l.w.len() > 13 { Some(l.xyz(13, "цель движения")?) } else { None };
|
||||
let mut comps = Vec::new();
|
||||
for k in 0..6 {
|
||||
match l.num(16 + k, "часть", 0.0, 1.0)? {
|
||||
Some(c) => comps.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out.sams.push(SamRec { kind, pos, heading, roles, health, cargo, ammo, dest, comps });
|
||||
}
|
||||
"target" => {
|
||||
if out.targets.len() >= MAX_UNITS {
|
||||
return Err(l.bad("число объектов", &format!("больше {MAX_UNITS}")));
|
||||
}
|
||||
let pos = l.xyz(1, "координаты")?;
|
||||
let hp = l.num(4, "прочность", 0.0, 100.0)?.unwrap_or(100.0);
|
||||
out.targets.push((pos, hp));
|
||||
}
|
||||
"damage" => out.damage = l.flag(1, "значение")?,
|
||||
"far" => out.far = l.flag(1, "значение")?,
|
||||
"effects" => out.effects = Some((l.flag(1, "шейдер")?.unwrap_or(true), l.flag(2, "HD")?.unwrap_or(false))),
|
||||
"seed" => {
|
||||
let v = l.w.get(1).ok_or_else(|| l.bad("значение", "строка оборвана"))?;
|
||||
out.seed = Some(v.parse::<u64>().map_err(|_| l.bad("значение", &format!("«{v}» не число")))?);
|
||||
}
|
||||
"wave" => {
|
||||
crate::wave::WaveConfig::default().check(&l.w[1..]).map_err(|e| l.bad("налёт", &e))?;
|
||||
out.wave = Some(l.w[1..].iter().map(|s| s.to_string()).collect());
|
||||
}
|
||||
"roles" => {
|
||||
let kind = l.kind(1)?;
|
||||
out.roles.push((kind, l.need(2, "роли", 0.0, 255.0)? as u8));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> &'static str {
|
||||
SamKind::Patriot.key()
|
||||
}
|
||||
|
||||
// #86: the issue's file, and the other ways a saved state can be wrong: refused whole, with the line
|
||||
#[test]
|
||||
fn a_damaged_state_is_refused_as_a_whole_with_its_line_and_field() {
|
||||
let good = format!("sam {} 10 70 20 0.5\ntarget 1 2 3 100\n", key());
|
||||
assert!(Layout::parse(&good).is_ok());
|
||||
let cases: Vec<(String, usize, &str)> = vec![
|
||||
("target NaN inf 0 -100\nsam s400 NaN 70 0 0\n".into(), 1, "координаты"),
|
||||
(format!("{good}target 1 inf 3 100\n"), 3, "не конечное"),
|
||||
(format!("{good}target 1 2 3 -100\n"), 3, "прочность"),
|
||||
(format!("{good}target 1 2 3 NaN\n"), 3, "не конечное"),
|
||||
(format!("{good}target 1 2\n"), 3, "оборвана"),
|
||||
(format!("{good}sam {} 1 2\n", key()), 3, "оборвана"),
|
||||
(format!("{good}sam nosuchkind 1 2 3 0\n"), 3, "тип"),
|
||||
(format!("{good}sam {} 1 2 3 0 0 7.5\n", key()), 3, "прочность"),
|
||||
(format!("{good}sam {} 1 2 3 0 0 1 0 1 1 0 0 - - - 0.5 abc\n", key()), 3, "часть"),
|
||||
(format!("{good}sam {} 1e400 2 3 0\n", key()), 3, "координаты"),
|
||||
(format!("{good}seed twelve\n"), 3, "значение"),
|
||||
(format!("{good}wave 1,2,x 0 300 0 1\n"), 3, "налёт"),
|
||||
(format!("{good}damage maybe\n"), 3, "значение"),
|
||||
];
|
||||
for (text, line, what) in cases {
|
||||
let e = Layout::parse(&text).err().unwrap_or_else(|| panic!("accepted: {text}"));
|
||||
assert!(e.contains(&format!("строка {line}")) && e.contains(what), "{e} for {text}");
|
||||
}
|
||||
let many = "target 0 0 0 100\n".repeat(MAX_UNITS + 1);
|
||||
assert!(Layout::parse(&many).is_err(), "no limit on the number of objects");
|
||||
}
|
||||
|
||||
// #86: files made by older builds (no ammunition, no parts, no destination marks) and unknown lines still load
|
||||
#[test]
|
||||
fn older_valid_saves_still_load() {
|
||||
let old = format!(
|
||||
"sam {k} 10.5 70 -20 1.57\nsam {k} 1 2 3 0 3 0.8 2\nsam {k} 1 2 3 0 3 0.8 2 4 12 1 30 - - -\nsam {k} 1 2 3 0 3 1 0 4 12 0 0 5 70 5 1 0.9 0.8 0.7 0.6 0.5\ntarget 5 6 7\ntarget 5 6 7 0\ndamage false\nfar true\neffects true false\nroles {k} 3\nfuture-line with words\nscenario 1\nname x\nworld state-0\nseed 123456789\nwave 0,1,0,2,0,0,0,0,0 1 300 0 1\n",
|
||||
k = key()
|
||||
);
|
||||
let l = Layout::parse(&old).expect("an old valid save was refused");
|
||||
assert_eq!((l.sams.len(), l.targets.len()), (4, 2));
|
||||
assert_eq!(l.targets[0].1, 100.0);
|
||||
assert_eq!(l.sams[2].ammo, Some((4, 12, true, 30)));
|
||||
assert!(l.sams[2].dest.is_none() && l.sams[3].dest == Some(DVec3::new(5.0, 70.0, 5.0)));
|
||||
assert_eq!(l.sams[3].comps.len(), 6);
|
||||
assert_eq!((l.damage, l.far, l.effects, l.seed), (Some(false), Some(true), Some((true, false)), Some(123456789)));
|
||||
assert_eq!(l.roles.len(), 1);
|
||||
// What the build writes now reads back (a wave line from the menu's own text).
|
||||
let w = crate::wave::WaveConfig::default().text();
|
||||
assert!(Layout::parse(&format!("wave {w}\n")).is_ok(), "{w}");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use horizon_mod_sdk::{
|
||||
};
|
||||
|
||||
mod cinema;
|
||||
mod commands;
|
||||
mod layout;
|
||||
mod film;
|
||||
mod monitor;
|
||||
mod panel;
|
||||
@@ -80,6 +82,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
|
||||
@@ -129,11 +133,22 @@ 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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A sound heard later: when, what, where, and how loud and high it was.
|
||||
struct PendingSound {
|
||||
due: u64,
|
||||
@@ -425,13 +440,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,
|
||||
@@ -535,13 +555,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)
|
||||
}
|
||||
|
||||
@@ -783,83 +801,79 @@ impl AirDef {
|
||||
|
||||
fn load_from(&mut self, key: &str) {
|
||||
let Some(bytes) = storage::get(key) else { return };
|
||||
self.apply_state(&String::from_utf8_lossy(&bytes));
|
||||
if let Err(e) = self.apply_state(&String::from_utf8_lossy(&bytes)) {
|
||||
horizon_mod_sdk::warn(&format!("saved state not loaded: {e}"));
|
||||
// The next autosave would write over it: keep the refused file for a look or a repair.
|
||||
if let Err(e) = storage::set(&format!("{key}-refused"), &bytes) {
|
||||
horizon_mod_sdk::warn(&format!("could not keep the refused state: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the state in `text` (`state_text`'s lines; unknown lines are skipped, so older and
|
||||
/// newer files read alike).
|
||||
fn apply_state(&mut self, text: &str) {
|
||||
for line in text.lines() {
|
||||
let w: Vec<&str> = line.split_whitespace().collect();
|
||||
let num = |i: usize| w.get(i).and_then(|v| v.parse::<f64>().ok());
|
||||
match w.first() {
|
||||
Some(&"sam") => {
|
||||
if let (Some(kind), Some(x), Some(y), Some(z), Some(h)) =
|
||||
(w.get(1).and_then(|k| SamKind::from_key(k)), num(2), num(3), num(4), num(5))
|
||||
{
|
||||
let id = self.sim.id();
|
||||
let mut sam = Sam::new(id, kind, DVec3::new(x, y, z), h);
|
||||
if let Some(r) = w.get(6).and_then(|v| v.parse().ok()) {
|
||||
sam.roles = r;
|
||||
}
|
||||
if let Some(h) = num(7) {
|
||||
sam.health = h.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(c) = num(8) {
|
||||
sam.cargo = c as u32;
|
||||
}
|
||||
// Older saves have no ammunition: full, as before.
|
||||
if let (Some(m), Some(b)) = (num(9), num(10)) {
|
||||
sam.missiles = (m as u32).min(sam.missiles);
|
||||
sam.bursts = (b as u32).min(sam.bursts);
|
||||
sam.empty = num(11) == Some(1.0);
|
||||
sam.reloading = num(12).map_or(0, |r| r as u32);
|
||||
}
|
||||
let dest = match (num(13), num(14), num(15)) {
|
||||
(Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)),
|
||||
_ => None,
|
||||
};
|
||||
// The parts: absent in older saves, which load with every part whole.
|
||||
for (k, v) in sam.comps.0.iter_mut().enumerate() {
|
||||
if let Some(c) = num(16 + k) {
|
||||
*v = c.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
sam.fit_cells();
|
||||
self.sim.sams.push(sam);
|
||||
if let Some(to) = dest {
|
||||
let i = self.sim.sams.len() - 1;
|
||||
self.sim.order_move(i, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(&"target") => {
|
||||
if let (Some(x), Some(y), Some(z)) = (num(1), num(2), num(3)) {
|
||||
self.sim.targets.push(DVec3::new(x, y, z));
|
||||
self.sim.target_health.push(num(4).unwrap_or(100.0));
|
||||
}
|
||||
}
|
||||
Some(&"damage") => self.sim.damage = w.get(1) != Some(&"false"),
|
||||
// A scenario's: the random generator's state, and the wave menu's raid.
|
||||
Some(&"seed") => {
|
||||
if let Some(v) = w.get(1).and_then(|v| v.parse::<u64>().ok()) {
|
||||
self.sim.rng = sim::Rng::new(v);
|
||||
}
|
||||
}
|
||||
Some(&"wave") => self.wave.parse(&w[1..]),
|
||||
Some(&"far") => self.far = w.get(1) != Some(&"false"),
|
||||
Some(&"effects") => {
|
||||
let shader = w.get(1) != Some(&"false");
|
||||
let hd = w.get(2) == Some(&"true");
|
||||
self.set_effects(shader, hd);
|
||||
}
|
||||
Some(&"roles") => {
|
||||
if let (Some(kind), Some(r)) = (w.get(1).and_then(|k| SamKind::from_key(k)), w.get(2).and_then(|v| v.parse().ok())) {
|
||||
self.sim.roles.insert(kind, r);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
/// newer files read alike). The whole text is read and checked first: a bad line refuses all of it,
|
||||
/// naming the line and field, and the scene stays as it was (#86).
|
||||
fn apply_state(&mut self, text: &str) -> Result<(), String> {
|
||||
let layout = layout::Layout::parse(text)?;
|
||||
self.commit_layout(layout);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a checked layout to the scene: nothing here can fail.
|
||||
fn commit_layout(&mut self, layout: layout::Layout) {
|
||||
for r in layout.sams {
|
||||
let id = self.sim.id();
|
||||
let mut sam = Sam::new(id, r.kind, r.pos, r.heading);
|
||||
if let Some(v) = r.roles {
|
||||
sam.roles = v;
|
||||
}
|
||||
if let Some(h) = r.health {
|
||||
sam.health = h;
|
||||
}
|
||||
if let Some(c) = r.cargo {
|
||||
sam.cargo = c;
|
||||
}
|
||||
// Older saves have no ammunition: full, as before.
|
||||
if let Some((m, b, empty, reloading)) = r.ammo {
|
||||
sam.missiles = m.min(sam.missiles);
|
||||
sam.bursts = b.min(sam.bursts);
|
||||
sam.empty = empty;
|
||||
sam.reloading = reloading;
|
||||
}
|
||||
// The parts: absent in older saves, which load with every part whole.
|
||||
for (v, c) in sam.comps.0.iter_mut().zip(&r.comps) {
|
||||
*v = *c;
|
||||
}
|
||||
sam.fit_cells();
|
||||
self.sim.sams.push(sam);
|
||||
if let Some(to) = r.dest {
|
||||
let i = self.sim.sams.len() - 1;
|
||||
self.sim.order_move(i, to);
|
||||
}
|
||||
}
|
||||
for (pos, hp) in layout.targets {
|
||||
self.sim.targets.push(pos);
|
||||
self.sim.target_health.push(hp);
|
||||
}
|
||||
if let Some(d) = layout.damage {
|
||||
self.sim.damage = d;
|
||||
}
|
||||
// A scenario's: the random generator, and the wave menu's raid.
|
||||
if let Some(v) = layout.seed {
|
||||
self.sim.reseed(v);
|
||||
}
|
||||
if let Some(w) = layout.wave {
|
||||
let w: Vec<&str> = w.iter().map(String::as_str).collect();
|
||||
self.wave.parse(&w);
|
||||
}
|
||||
if let Some(f) = layout.far {
|
||||
self.far = f;
|
||||
}
|
||||
if let Some((shader, hd)) = layout.effects {
|
||||
self.set_effects(shader, hd);
|
||||
}
|
||||
for (kind, r) in layout.roles {
|
||||
self.sim.roles.insert(kind, r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,6 +979,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());
|
||||
@@ -983,7 +998,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(())
|
||||
@@ -994,6 +1013,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);
|
||||
@@ -1010,6 +1035,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());
|
||||
@@ -1028,6 +1056,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);
|
||||
@@ -1071,13 +1102,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;
|
||||
@@ -1331,7 +1369,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> {
|
||||
@@ -1352,6 +1393,7 @@ impl Mod for AirDef {
|
||||
time_view: false,
|
||||
export: None,
|
||||
film_prev: None,
|
||||
session: 0,
|
||||
smoke: Default::default(),
|
||||
far: true,
|
||||
moving: None,
|
||||
@@ -1384,6 +1426,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()))
|
||||
}
|
||||
|
||||
@@ -1908,7 +1952,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)
|
||||
@@ -1916,3 +1960,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -114,13 +125,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 +156,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 +173,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 +190,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 }
|
||||
@@ -419,7 +441,7 @@ impl Player {
|
||||
return;
|
||||
}
|
||||
sim.draw_ghosts(rec, self.t, GHOSTS, out);
|
||||
self.direct(rec);
|
||||
self.direct(rec, ground);
|
||||
self.sounds(rec, listener, out);
|
||||
}
|
||||
|
||||
@@ -428,13 +450,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
|
||||
@@ -565,7 +590,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 +627,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 +659,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 +673,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 +986,21 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -49,6 +59,8 @@ impl AirDef {
|
||||
if !force && world != self.state_key() {
|
||||
return Err("Сценарий сделан в другом мире: координаты могут не подойти (загрузить всё равно: добавьте force)".into());
|
||||
}
|
||||
// The whole body is read and checked before anything is touched (#86).
|
||||
let layout = layout::Layout::parse(text).map_err(|e| format!("Сценарий не загружен, сцена осталась прежней: {e}"))?;
|
||||
if self.replay.is_some() {
|
||||
self.stop_replay();
|
||||
}
|
||||
@@ -67,7 +79,7 @@ impl AirDef {
|
||||
self.moving = None;
|
||||
self.pending.clear();
|
||||
render::set_models(&[]);
|
||||
self.apply_state(text);
|
||||
self.commit_layout(layout);
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -80,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(),
|
||||
@@ -107,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()),
|
||||
|
||||
@@ -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,7 @@ mod ew;
|
||||
pub mod lights;
|
||||
mod logistics;
|
||||
mod orders;
|
||||
pub mod outcome;
|
||||
mod units;
|
||||
mod why;
|
||||
pub mod world;
|
||||
@@ -30,6 +32,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};
|
||||
@@ -1374,6 +1377,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 },
|
||||
}
|
||||
@@ -1393,7 +1398,13 @@ pub struct Sim {
|
||||
/// The next blast's id (separate from `next`, so the other units' ids stay as they were).
|
||||
blast_seq: u32,
|
||||
next: u32,
|
||||
/// The combat randomness: what is rolled for aim, hits, damage and raids. Drawing, sound and camera never
|
||||
/// spend it (#85).
|
||||
pub rng: Rng,
|
||||
/// The decorative randomness (flicker, pitch, spin, scatter of tracers and debris), and the ids of what is
|
||||
/// only drawn: neither moves with the graphics settings of the combat.
|
||||
vis: Rng,
|
||||
fx_next: u32,
|
||||
pub tick: u64,
|
||||
/// The game's tick number (the one `tick` is called with), for a recording to know the world's time by.
|
||||
pub engine_tick: u64,
|
||||
@@ -1418,6 +1429,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).
|
||||
@@ -1447,6 +1460,15 @@ pub struct Sim {
|
||||
|
||||
/// Ids: objects get multiples of 16, their parts the low bits.
|
||||
const PARTS: u32 = 16;
|
||||
/// Ids of what is only drawn (debris, fireball models, tracers) come from their own range and counter, so that
|
||||
/// the number of effects never moves the ids of the combat's objects (#85).
|
||||
const FX_IDS: u32 = 0x2000_0000;
|
||||
|
||||
/// The next id for a drawn-only thing.
|
||||
fn fx_ident(counter: &mut u32) -> u32 {
|
||||
*counter = (*counter + 1) % 0x0100_0000;
|
||||
FX_IDS + *counter * PARTS
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
@@ -1461,6 +1483,8 @@ impl Sim {
|
||||
blast_seq: 0,
|
||||
next: 1,
|
||||
rng: Rng::new(seed),
|
||||
vis: Rng::new(seed ^ 0x5DEE_CE66_D1CE_4E5B),
|
||||
fx_next: 0,
|
||||
tick: 0,
|
||||
engine_tick: 0,
|
||||
kills: 0,
|
||||
@@ -1475,6 +1499,7 @@ impl Sim {
|
||||
horizons: HashMap::new(),
|
||||
later: Vec::new(),
|
||||
queue: Vec::new(),
|
||||
sites: sites::Sites::default(),
|
||||
plan: None,
|
||||
target_health: Vec::new(),
|
||||
wave: None,
|
||||
@@ -1491,6 +1516,14 @@ impl Sim {
|
||||
spot_hd: false,
|
||||
}
|
||||
}
|
||||
/// A scenario's start: the combat generator, the dice of stateless rolls and the decorative generator all
|
||||
/// come from `state` alone, whatever was played before. The clock and the id counter go on (what is in the
|
||||
/// world keeps its ids), so a restart repeats the draws, not the whole fight.
|
||||
pub fn reseed(&mut self, state: u64) {
|
||||
self.rng = Rng::new(state);
|
||||
self.vis = Rng::new(state ^ 0x5DEE_CE66_D1CE_4E5B);
|
||||
self.seed = state.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1;
|
||||
}
|
||||
pub fn id(&mut self) -> u32 {
|
||||
self.next += 1;
|
||||
self.next * PARTS
|
||||
@@ -1572,6 +1605,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.
|
||||
@@ -1586,7 +1620,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);
|
||||
@@ -1598,6 +1632,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);
|
||||
@@ -1645,7 +1680,7 @@ impl Sim {
|
||||
} else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) {
|
||||
d.pos = from.lerp(d.pos, t);
|
||||
}
|
||||
if self.rng.chance(0.5) {
|
||||
if self.vis.chance(0.5) {
|
||||
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);
|
||||
@@ -1976,14 +2011,14 @@ impl Sim {
|
||||
}
|
||||
Engine::Jet(up, back, size) => {
|
||||
let at = d.pos + rot * DVec3::new(0.0, up * s, back * s);
|
||||
let flicker = 0.7 + 0.3 * self.rng.f();
|
||||
let flicker = 0.7 + 0.3 * self.vis.f();
|
||||
out.model(d.id + 1, "airdef:geran3_flame", at, rot, s * size * flicker, true);
|
||||
}
|
||||
Engine::Rocket(back, size) => {
|
||||
// Ballistic missiles fly far and high: their glow keeps a size on screen, so they are seen
|
||||
// all the way (the motor while it burns, then the hot body).
|
||||
let far = player.map_or(1.0, |p| (d.pos.distance(p) / 140.0).max(1.0));
|
||||
let flicker = 0.8 + 0.2 * self.rng.f();
|
||||
let flicker = 0.8 + 0.2 * self.vis.f();
|
||||
if d.flight != Flight::Ballistic || d.age < 60 {
|
||||
let size = if d.flight == Flight::Ballistic { size * far.sqrt() } else { size };
|
||||
let at = d.pos + rot * DVec3::new(0.0, 0.0, back * s + size * flicker);
|
||||
@@ -2019,7 +2054,7 @@ impl Sim {
|
||||
out.particles("minecraft:flame", pos, size as f32 * 0.3, DVec3::ZERO, 0.2, n);
|
||||
out.particles("minecraft:lava", pos, 1.0, DVec3::ZERO, 0.0, 16);
|
||||
out.particles("minecraft:campfire_signal_smoke", pos, 1.2, DVec3::new(0.0, 0.04, 0.0), 0.01, if big { 30 } else { 10 });
|
||||
let pitch = 0.9 + self.rng.f() as f32 * 0.2;
|
||||
let pitch = 0.9 + self.vis.f() as f32 * 0.2;
|
||||
let event = ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][power];
|
||||
out.sound(event, pos, 1.0, if small { pitch * 1.15 } else { pitch });
|
||||
if power >= 3 {
|
||||
@@ -2058,16 +2093,32 @@ impl Sim {
|
||||
if self.shader {
|
||||
return;
|
||||
}
|
||||
let id = self.id();
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.rng.s() * 0.2, self.rng.s() * 0.2, self.rng.s() * 0.2);
|
||||
let id = fx_ident(&mut self.fx_next);
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.2, self.vis.s() * 0.2, self.vis.s() * 0.2);
|
||||
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;
|
||||
@@ -2075,33 +2126,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.rng.f() as f32 * 0.2);
|
||||
for _ in 0..5 {
|
||||
let id = self.id();
|
||||
let v = vel + DVec3::new(self.rng.s(), self.rng.f() * 0.8, self.rng.s()) * 0.35;
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.rng.s() * 0.4, self.rng.s() * 0.4, self.rng.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
|
||||
@@ -2333,16 +2423,15 @@ impl Sim {
|
||||
let dir = (lead - muzzle).normalize_or(DVec3::NEG_Z);
|
||||
let len = muzzle.distance(lead).min(gun.range * 1.2);
|
||||
// Every round is a red tracer flying out at the shell's speed.
|
||||
let scatter = DVec3::new(self.rng.s(), self.rng.s(), self.rng.s()) * 0.012;
|
||||
let id = self.next * PARTS + 0x0800_0000;
|
||||
self.next += 1;
|
||||
let scatter = DVec3::new(self.vis.s(), self.vis.s(), self.vis.s()) * 0.012;
|
||||
let id = fx_ident(&mut self.fx_next);
|
||||
tracers.push((id, muzzle + dir * 1.5, (dir + scatter).normalize() * gun.shell, (len / gun.shell) as u32 + 2));
|
||||
out.particles("minecraft:smoke", muzzle, 0.1, dir * 0.05, 0.01, 2);
|
||||
}
|
||||
// A gun sound holds a few shots: one every other burst.
|
||||
if sam.bursts % 2 == 0 {
|
||||
let event = if sam.kind == SamKind::Pantsir { "airdef:gun.pantsir" } else { "airdef:gun.gepard" };
|
||||
out.sound(event, mount, 1.0, 0.95 + self.rng.f() as f32 * 0.1);
|
||||
out.sound(event, mount, 1.0, 0.95 + self.vis.f() as f32 * 0.1);
|
||||
}
|
||||
// At night a gun sees what is lit (a searchlight, a flare) and little else.
|
||||
let dark = if self.night && !self.lit.contains(&t.id) { lights::DARK_HITS } else { 1.0 };
|
||||
@@ -2448,7 +2537,7 @@ impl Sim {
|
||||
out.particles("minecraft:cloud", muzzle - dir * 2.0, 1.4, DVec3::new(0.0, 0.02, 0.0), 0.06, 70);
|
||||
out.particles("minecraft:campfire_cosy_smoke", muzzle - dir * 3.0, 1.2, DVec3::ZERO, 0.02, 20);
|
||||
let event = if kind == MissileKind::M57e6 { "airdef:launch.pantsir" } else { "airdef:launch.patriot" };
|
||||
out.sound(event, muzzle, 1.0, 0.95 + self.rng.f() as f32 * 0.1);
|
||||
out.sound(event, muzzle, 1.0, 0.95 + self.vis.f() as f32 * 0.1);
|
||||
}
|
||||
}
|
||||
for (id, pos, vel, life) in tracers {
|
||||
@@ -2456,7 +2545,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() {
|
||||
@@ -2478,7 +2567,7 @@ impl Sim {
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let s = self.sams.iter().find(|s| s.id == m.sam)?;
|
||||
(s.alive() && !s.moving()).then_some(())?;
|
||||
(s.alive() && !s.moving() && s.link_range() > 0.0).then_some(())?;
|
||||
Some(((m.sam, m.target), *s.track(m.target)?))
|
||||
})
|
||||
.collect();
|
||||
@@ -2507,12 +2596,12 @@ impl Sim {
|
||||
let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6);
|
||||
let to = t.pos - m.pos;
|
||||
let in_view = to.length() < range && to.normalize_or_zero().dot(dir) > 0.5;
|
||||
if in_view && !matches!(ground.world.probe(m.pos, t.pos, now), world::Sweep::Hit { .. }) {
|
||||
// Only a line of sight the world has confirmed counts: blocks not read (Unknown) are not air (#75).
|
||||
if in_view && matches!(ground.world.probe(m.pos, t.pos, now), world::Sweep::Clear) {
|
||||
m.seen = now;
|
||||
}
|
||||
if m.seen > 0 && now.saturating_sub(m.seen) <= REACQUIRE {
|
||||
// The seeker measures the target (the track of its own, newer than any link's).
|
||||
m.fix = Some(Fix { pos: if m.seen == now { t.pos } else { m.fix.map_or(t.pos, |f| f.pos) }, vel: t.vel, tick: if m.seen == now { now } else { m.fix.map_or(now, |f| f.tick) }, seeker: true });
|
||||
// The seeker measures the target: position and velocity, now. Out of sight (behind a hill,
|
||||
// an unread section) nothing new is measured and the last fix is carried on as it was.
|
||||
m.fix = Some(Fix { pos: t.pos, vel: t.vel, tick: now, seeker: true });
|
||||
}
|
||||
}
|
||||
// Nothing new for too long and no seeker on it: the interceptor has lost its target.
|
||||
@@ -2544,6 +2633,9 @@ impl Sim {
|
||||
let before = m.pos;
|
||||
m.vel = new * speed;
|
||||
m.pos += m.vel;
|
||||
let end = m.pos;
|
||||
// Where the exact shape is still being asked for, the contact point (by the cube) bounds the fuse (#74).
|
||||
let mut waiting: Option<f64> = None;
|
||||
// The first block on the way this tick; a fuse that goes off earlier along the way still counts.
|
||||
ground.ahead(m.pos, m.vel, 10.0, now);
|
||||
let sweep = ground.trace(m.id | 0x8000_0000, before, m.pos, m.age <= 12, now);
|
||||
@@ -2561,16 +2653,19 @@ impl Sim {
|
||||
}
|
||||
} else if let world::Sweep::Wait { t } = sweep {
|
||||
m.pos = before.lerp(m.pos, (t - 1e-6).max(0.0));
|
||||
waiting = Some(t);
|
||||
} else if matches!(sweep, world::Sweep::Clear) {
|
||||
m.hold = 0;
|
||||
}
|
||||
if let Some(t) = target {
|
||||
// Closest approach this tick, both moving (fast targets cross a fuse in a tick).
|
||||
let rel0 = (t.pos - t.vel) - before;
|
||||
let rel1 = t.pos - m.pos;
|
||||
// Waiting for the shape: the fuse is judged on the whole step against the cube's face, as for a
|
||||
// known hit, so that a pending answer is neither a free pass nor a different outcome.
|
||||
let rel1 = t.pos - if waiting.is_some() { end } else { m.pos };
|
||||
let delta = rel1 - rel0;
|
||||
let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0);
|
||||
if (rel0 + delta * k).length() < spec.fuse && world.is_none_or(|(w, _)| k <= w) {
|
||||
if (rel0 + delta * k).length() < spec.fuse && world.map(|w| w.0).or(waiting).is_none_or(|w| k <= w) {
|
||||
// A target faster than the interceptor is hard to hit.
|
||||
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
|
||||
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0);
|
||||
@@ -2643,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.
|
||||
@@ -2660,7 +2756,7 @@ impl Sim {
|
||||
out.model(m.id, spec.model, m.pos, rot, spec.scale, false);
|
||||
if m.age > spec.cold && spec.flame > 0.0 {
|
||||
let back = (spec.rear - 8.0) / 16.0 * spec.scale + spec.flame;
|
||||
let size = spec.flame * if m.age < 40 { 1.2 } else { 0.8 } * (0.85 + 0.15 * self.rng.f());
|
||||
let size = spec.flame * if m.age < 40 { 1.2 } else { 0.8 } * (0.85 + 0.15 * self.vis.f());
|
||||
out.model(m.id + 1, "airdef:flame", m.pos + rot * DVec3::new(0.0, 0.0, back), rot, size, true);
|
||||
}
|
||||
}
|
||||
@@ -2694,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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +442,51 @@ fn missile_cannot_pass_through_a_wall() {
|
||||
assert!(sim.blasts.iter().all(|b| b.at.x < 3.5), "burst behind the wall");
|
||||
}
|
||||
|
||||
// #74: a pending exact answer is no free pass for the fuse: the same outcome whether the reply is late or not
|
||||
#[test]
|
||||
fn pending_exact_answer_does_not_let_the_fuse_kill_through_a_wall() {
|
||||
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
|
||||
let run = |seed: u64, target_x: f64, reply: bool| {
|
||||
let mut sim = Sim::new(seed);
|
||||
let mut ground = Ground::default();
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(target_x, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
sim.drones[0].engaged = 1;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.tick = 20;
|
||||
let fix = Fix { pos: DVec3::new(target_x, 100.0, 0.0), vel: DVec3::ZERO, tick: 20, seeker: false };
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 6.0, target, sam: 0, age: 20, hold: 0, fix: Some(fix), seen: 0 });
|
||||
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
|
||||
let mut first = 0;
|
||||
for i in 0..4 {
|
||||
sim.tick += 1;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
if i == 0 {
|
||||
first = sim.kills;
|
||||
}
|
||||
let wanted = ground.world.take_wanted(sim.tick);
|
||||
if reply {
|
||||
ground.world.serve(&src, wanted, sim.tick);
|
||||
} else {
|
||||
// Sections only: the exact rays are never answered inside the test.
|
||||
let answers = wanted.into_iter().map(|sec| world::SectionAnswer { sec, fill: world::section_of(&src, sec) }).collect();
|
||||
ground.world.answer(answers, sim.tick);
|
||||
}
|
||||
}
|
||||
(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.
|
||||
for reply in [true, false] {
|
||||
let (first, total, alive) = run(seed, 5.5, reply);
|
||||
assert_eq!((first, total, alive), (0, 0, true), "seed {seed} reply {reply}: kill through the wall");
|
||||
}
|
||||
}
|
||||
// In front of the wall the fuse still works, with the reply late or on time.
|
||||
let kills = |reply| (1..=10).filter(|&seed| run(seed, 2.0, reply).1 > 0).count();
|
||||
assert!(kills(true) > 0 && kills(true) == kills(false), "{} vs {}", kills(true), kills(false));
|
||||
}
|
||||
|
||||
// #42, #43: a bridge deck above the flight is no ground under it
|
||||
#[test]
|
||||
fn flight_under_a_bridge_is_not_a_ground_hit() {
|
||||
@@ -609,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)
|
||||
}
|
||||
@@ -1087,6 +1195,89 @@ fn partial_blocks_use_exact_shapes() {
|
||||
assert!(matches!(last, world::Sweep::Hit { .. }), "{last:?}");
|
||||
}
|
||||
|
||||
// #73: an exact answer belongs to the segment and the geometry it was asked of
|
||||
#[test]
|
||||
fn exact_answers_are_not_reused_for_other_segments_or_changed_blocks() {
|
||||
use std::cell::Cell;
|
||||
let cube = Cell::new(false);
|
||||
let world = Shaped(|x, y, z| {
|
||||
if y < 64 {
|
||||
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
|
||||
} else if (x, y, z) == (10, 70, 0) {
|
||||
Some(vec![[0.0, 0.0, 0.0, 1.0, if cube.get() { 1.0 } else { 0.5 }, 1.0]])
|
||||
} else {
|
||||
Some(vec![])
|
||||
}
|
||||
});
|
||||
let mut g = Ground::default();
|
||||
g.world.changed(1, false, &[]);
|
||||
let (upper_a, upper_b) = (DVec3::new(9.5, 70.75, 0.5), DVec3::new(11.5, 70.75, 0.5));
|
||||
let (lower_a, lower_b) = (DVec3::new(9.5, 70.25, 0.5), DVec3::new(11.5, 70.25, 0.5));
|
||||
// Settles a step: waits for the game's answers the way a held flight does.
|
||||
let now = Cell::new(1u64);
|
||||
let settle = |g: &mut Ground, a: DVec3, b: DVec3| {
|
||||
let mut r = g.trace(7, a, b, false, now.get());
|
||||
for _ in 0..8 {
|
||||
if !matches!(r, world::Sweep::Wait { .. } | world::Sweep::Unknown { .. }) {
|
||||
break;
|
||||
}
|
||||
let q = g.world.take_wanted(now.get());
|
||||
g.world.serve(&world, q, now.get());
|
||||
now.set(now.get() + 1);
|
||||
r = g.trace(7, a, b, false, now.get());
|
||||
}
|
||||
r
|
||||
};
|
||||
assert_eq!(settle(&mut g, upper_a, upper_b), world::Sweep::Clear);
|
||||
// The same flight and block, the other half of the slab: not the old Clear, a new question.
|
||||
now.set(now.get() + 1);
|
||||
let r = g.trace(7, lower_a, lower_b, false, now.get());
|
||||
assert!(matches!(r, world::Sweep::Wait { .. }), "old Clear reused through the slab: {r:?}");
|
||||
let r = settle(&mut g, lower_a, lower_b);
|
||||
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 0], .. }), "{r:?}");
|
||||
// A repeat of the same step is served from memory and agrees with a fresh ask.
|
||||
let r2 = g.trace(7, lower_a, lower_b, false, now.get() + 1);
|
||||
assert_eq!(r, r2);
|
||||
// The slab becomes a full cube: the section is read again, the old Clear is void.
|
||||
assert_eq!(settle(&mut g, upper_a, upper_b), world::Sweep::Clear, "the upper step is clear again over the slab");
|
||||
cube.set(true);
|
||||
g.world.changed(1, false, &[(0, 4, 0)]);
|
||||
now.set(now.get() + 1);
|
||||
let r = g.trace(7, upper_a, upper_b, false, now.get());
|
||||
assert!(!matches!(r, world::Sweep::Clear), "Clear survived the block's replacement: {r:?}");
|
||||
// Not the cache's data but the game's: the section is read and the ray asked again.
|
||||
let r = settle(&mut g, upper_a, upper_b);
|
||||
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 0], .. }), "{r:?}");
|
||||
// A late reply to a question asked before a change does not touch the new world.
|
||||
cube.set(false);
|
||||
let mut h = Ground::default();
|
||||
h.world.changed(1, false, &[]);
|
||||
h.world.sweep(upper_a, upper_b, false, 1);
|
||||
let q = h.world.take_wanted(1);
|
||||
h.world.serve(&world, q, 1);
|
||||
let r = h.trace(8, upper_a, upper_b, false, 2);
|
||||
assert!(matches!(r, world::Sweep::Wait { .. }), "{r:?}");
|
||||
let old = h.world.take_rays(2);
|
||||
assert_eq!(old.len(), 1);
|
||||
cube.set(true);
|
||||
h.world.changed(1, false, &[(0, 4, 0)]);
|
||||
h.world.serve_rays(&world, old);
|
||||
let r = h.trace(8, upper_a, upper_b, false, 3);
|
||||
assert!(!matches!(r, world::Sweep::Clear), "a late reply was applied to the new world: {r:?}");
|
||||
// The wait is bounded however often the question is asked again.
|
||||
let mut h = Ground::default();
|
||||
h.world.changed(1, false, &[]);
|
||||
h.world.sweep(upper_a, upper_b, false, 1);
|
||||
let q = h.world.take_wanted(1);
|
||||
h.world.serve(&world, q, 1);
|
||||
let mut last = world::Sweep::Clear;
|
||||
for i in 0..60u64 {
|
||||
let dy = DVec3::new(0.0, 0.001 * i as f64, 0.0);
|
||||
last = h.trace(9, lower_a + dy, lower_b + dy, false, 2 + i);
|
||||
}
|
||||
assert!(matches!(last, world::Sweep::Hit { .. }), "a flight changing course every tick hangs: {last:?}");
|
||||
}
|
||||
|
||||
// #64: heights known are old when blocks change, a chunk is replaced, or time passes; they are asked again
|
||||
#[test]
|
||||
fn land_heights_follow_world_changes_and_age() {
|
||||
@@ -1157,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));
|
||||
@@ -1206,7 +1399,15 @@ fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
|
||||
let stale = Some(Fix { pos: DVec3::new(-60.0, 100.0, 80.0), vel: DVec3::ZERO, tick: 90, seeker: false });
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: stale, seen: 0 });
|
||||
sim.tick = 100;
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
// The world is read: open air between them is a confirmed line of sight.
|
||||
let air = Blocks(|_, y, _| Some(y < 64));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..16 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), at, t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&air, q, t);
|
||||
}
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
(sim.missiles[0].vel, sim.missiles[0].seen)
|
||||
};
|
||||
let (v, seen) = near(DVec3::new(40.0, 100.0, 70.0));
|
||||
@@ -1221,7 +1422,7 @@ fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 90, seeker: false }), seen: 0 });
|
||||
let hill = Blocks(|x, y, z| Some(y < 64 || ((10..14).contains(&x) && (0..40).contains(&z) && y < 300)));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..6 {
|
||||
for t in 0..16 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(40.0, 100.0, 70.0), t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&hill, q, t);
|
||||
@@ -1241,6 +1442,114 @@ fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
|
||||
assert!(sim.missiles.is_empty() && sim.tally.expired == 1, "{:?}", sim.tally);
|
||||
}
|
||||
|
||||
// #75: unread blocks are not a line of sight; out of sight the seeker coasts on its last estimate whole
|
||||
#[test]
|
||||
fn seeker_needs_confirmed_sight_and_coasts_on_its_last_estimate() {
|
||||
let setup = |target_at: DVec3, tv: DVec3, fix: Option<Fix>| {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Shahed, target_at, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
|
||||
sim.drones[0].vel = tv;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix, seen: 0 });
|
||||
sim.tick = 100;
|
||||
sim
|
||||
};
|
||||
let at = DVec3::new(0.0, 100.0, 70.0);
|
||||
// Cold world: nothing read, nothing locked, however close the target is.
|
||||
let mut sim = setup(DVec3::new(20.0, 100.0, 70.0), DVec3::ZERO, None);
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
assert!(sim.missiles.iter().all(|m| m.seen == 0 && m.fix.is_none()), "an unread world was taken for line of sight");
|
||||
// Warm open air: locked.
|
||||
let air = Blocks(|_, y, _| Some(y < 64));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..16 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), at, t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&air, q, t);
|
||||
}
|
||||
let mut sim = setup(at, DVec3::new(0.5, 0.0, 0.0), None);
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
let f = sim.missiles[0].fix.expect("no lock in clear air");
|
||||
assert!(sim.missiles[0].seen == 100 && f.seeker && f.tick == 100 && f.vel == DVec3::new(0.5, 0.0, 0.0), "{f:?}");
|
||||
// Then a wall comes between them and the target manoeuvres: the estimate stays as it was, pos, vel and tick.
|
||||
let wall = Blocks(|x, y, z| Some(y < 64 || (x == 5 && y < 200 && z.abs() < 200)));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..16 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(10.0, 100.0, 70.0), t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&wall, q, t);
|
||||
}
|
||||
let last = Fix { pos: at, vel: DVec3::ZERO, tick: 100, seeker: true };
|
||||
let mut sim = setup(DVec3::new(10.0, 100.0, 70.0), DVec3::new(2.0, 0.0, 0.0), Some(last));
|
||||
sim.missiles[0].seen = 100;
|
||||
sim.tick = 101;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
let f = sim.missiles[0].fix.expect("the estimate was dropped");
|
||||
assert!(f.pos == last.pos && f.vel == DVec3::ZERO && f.tick == 100, "truth leaked through the wall: {f:?}");
|
||||
}
|
||||
|
||||
// #85: the graphics settings never reach the combat: the same seed and commands play out the same
|
||||
#[test]
|
||||
fn graphics_settings_do_not_change_combat_randomness_or_ids() {
|
||||
// The issue's case: one explosion, only the shader differs.
|
||||
let after_blast = |shader: bool| {
|
||||
let mut sim = Sim::new(12345);
|
||||
sim.shader = shader;
|
||||
sim.fireball(DVec3::new(0.0, 80.0, 0.0), 4.0, 11);
|
||||
let id = sim.id();
|
||||
let aim = sim.aim_error(DroneKind::Shahed);
|
||||
(id, aim)
|
||||
};
|
||||
assert_eq!(after_blast(true), after_blast(false), "a fireball spent entity ids or combat randomness");
|
||||
// A shot-down drone, with its debris and sound: no more than the same shot-down without them.
|
||||
let after_kill = |shader: bool, effects: u32| {
|
||||
let mut sim = Sim::new(99);
|
||||
sim.shader = shader;
|
||||
let mut out = Out::default();
|
||||
for i in 0..effects {
|
||||
sim.fireball(DVec3::new(f64::from(i), 80.0, 0.0), 4.0, 11);
|
||||
}
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(900.0, 80.0, 0.0));
|
||||
sim.shoot_down(0, 0, None, &mut out);
|
||||
let id = sim.id();
|
||||
(sim.drones[0].id, id, sim.aim_error(DroneKind::Shahed))
|
||||
};
|
||||
assert_eq!(after_kill(true, 0), after_kill(false, 0));
|
||||
assert_eq!(after_kill(false, 3), after_kill(true, 9));
|
||||
// A whole raid: the combat's outcome is the same with the shader on and off (and drawn or not).
|
||||
let play = |shader: bool, drawn: bool| {
|
||||
let mut sim = Sim::new(31);
|
||||
sim.shader = shader;
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for (i, kind) in [SamKind::Patriot, SamKind::Pantsir, SamKind::Gepard, SamKind::Patriot].into_iter().enumerate() {
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, kind, DVec3::new(i as f64 * 25.0 - 40.0, 70.0, 10.0 * i as f64), 0.0));
|
||||
}
|
||||
sim.raid(DroneKind::Shahed, DVec3::new(0.0, 70.0, 0.0), 14, 500.0, &mut out);
|
||||
// Whatever the settings, the world answers the same: nothing but the land is asked for here.
|
||||
let mut trace = Vec::new();
|
||||
for _ in 0..1500 {
|
||||
if !drawn {
|
||||
out = Out::default();
|
||||
}
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if out.models.len() > 20_000 {
|
||||
out = Out::default();
|
||||
}
|
||||
trace.push((sim.kills, sim.hits, sim.launched, sim.drones.len(), sim.missiles.len()));
|
||||
}
|
||||
let drones: Vec<_> = sim.drones.iter().map(|d| (d.id, d.pos.to_array().map(f64::to_bits))).collect();
|
||||
let missiles: Vec<_> = sim.missiles.iter().map(|m| (m.id, m.pos.to_array().map(f64::to_bits))).collect();
|
||||
let sams: Vec<_> = sim.sams.iter().map(|s| (s.id, s.kills)).collect();
|
||||
(trace, drones, missiles, sams, sim.crashes, sim.tally.blocked, sim.tally.expired)
|
||||
};
|
||||
let (a, b) = (play(true, true), play(false, false));
|
||||
assert!(a.0.last().is_some_and(|t| t.2 > 0), "nothing was launched: the test proves nothing");
|
||||
assert_eq!(a, b, "the combat differs with the graphics");
|
||||
}
|
||||
|
||||
// #69: many separate strikes: the list, what is drawn and the lights are bounded; the rest end properly
|
||||
#[test]
|
||||
fn mass_strikes_keep_fires_effects_and_lights_bounded() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -631,7 +631,7 @@ impl Sam {
|
||||
impl Sim {
|
||||
/// Draws unit `i`: its vehicle, its launcher or turret where it points, its antennas turning.
|
||||
pub(super) fn draw_sam(&mut self, i: usize, out: &mut Out) {
|
||||
draw_sam_model(&self.sams[i], &mut self.rng, out);
|
||||
draw_sam_model(&self.sams[i], &mut self.vis, out);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ struct RayEntry {
|
||||
b: DVec3,
|
||||
state: Exact,
|
||||
asked: u64,
|
||||
/// When this wait began: re-asking for a changed step does not restart the clock (#73).
|
||||
first: u64,
|
||||
/// No usable exact answer (timed out, or no verdict): the solid cube of the sections stands.
|
||||
cube: bool,
|
||||
}
|
||||
|
||||
/// The game's verdict on an exact ray, as `render.rays` gives it.
|
||||
@@ -128,8 +132,28 @@ pub struct RayReply {
|
||||
/// Ticks an exact ray may take to be answered before the block counts as the solid cube the sections say.
|
||||
const RAY_WAIT: u64 = 30;
|
||||
/// Exact rays asked in one tick (the game's budget is 256).
|
||||
/// How many steps long the exact ray is asked.
|
||||
const RAY_REACH: f64 = 3.0;
|
||||
const RAYS_PER_TICK: usize = 128;
|
||||
|
||||
/// Whether step `a`..`b` lies on the asked segment `a0`..`b0` (same line, same way, inside it, give or take a
|
||||
/// hair): the fractions of its ends along the asked one.
|
||||
fn within(a0: DVec3, b0: DVec3, a: DVec3, b: DVec3) -> Option<(f64, f64)> {
|
||||
const EPS: f64 = 1e-3;
|
||||
let d0 = b0 - a0;
|
||||
let l2 = d0.length_squared();
|
||||
if l2 < 1e-12 {
|
||||
return (a.distance(a0) < EPS && b.distance(b0) < EPS).then_some((0.0, 1.0));
|
||||
}
|
||||
let (sa, sb) = ((a - a0).dot(d0) / l2, (b - a0).dot(d0) / l2);
|
||||
let len = l2.sqrt();
|
||||
let tol = EPS / len;
|
||||
if sa < -tol || sb > 1.0 + tol || sb <= sa || a.distance(a0 + d0 * sa) > EPS || b.distance(a0 + d0 * sb) > EPS {
|
||||
return None;
|
||||
}
|
||||
Some((sa.max(0.0), sb.min(1.0)))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RayDesk {
|
||||
next: u32,
|
||||
@@ -215,6 +239,7 @@ impl WorldCache {
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
if epoch != self.epoch {
|
||||
self.rays = RayDesk { next: self.rays.next, ..RayDesk::default() };
|
||||
if self.epoch != 0 {
|
||||
self.secs.clear();
|
||||
self.urgent.clear();
|
||||
@@ -227,6 +252,7 @@ impl WorldCache {
|
||||
}
|
||||
if overflow || unexplained {
|
||||
self.changed_all = true;
|
||||
self.drop_rays(|_| true);
|
||||
}
|
||||
if overflow {
|
||||
for e in self.secs.values_mut() {
|
||||
@@ -234,6 +260,10 @@ impl WorldCache {
|
||||
}
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
if !sections.is_empty() {
|
||||
let set: HashSet<Sec> = sections.iter().copied().collect();
|
||||
self.drop_rays(|b| set.contains(&sec_of(b[0], b[1], b[2])));
|
||||
}
|
||||
for s in sections {
|
||||
self.note_column(s.0, s.2);
|
||||
if let Some(e) = self.secs.get_mut(s) {
|
||||
@@ -272,6 +302,7 @@ impl WorldCache {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.drop_rays(|b| set.contains(&(b[0] >> 4, b[2] >> 4)));
|
||||
self.stats.invalidated += n;
|
||||
self.stats.columns_changed += columns.len() as u64;
|
||||
}
|
||||
@@ -280,6 +311,10 @@ impl WorldCache {
|
||||
pub fn touched(&mut self, at: DVec3, radius: f64) {
|
||||
let lo = sec_of((at.x - radius).floor() as i32, (at.y - radius).floor() as i32, (at.z - radius).floor() as i32);
|
||||
let hi = sec_of((at.x + radius).floor() as i32, (at.y + radius).floor() as i32, (at.z + radius).floor() as i32);
|
||||
self.drop_rays(|b| {
|
||||
let s = sec_of(b[0], b[1], b[2]);
|
||||
(lo.0..=hi.0).contains(&s.0) && (lo.1..=hi.1).contains(&s.1) && (lo.2..=hi.2).contains(&s.2)
|
||||
});
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
@@ -462,33 +497,70 @@ impl WorldCache {
|
||||
pub fn narrow(&mut self, key: u32, hit: Sweep, a: DVec3, b: DVec3, now: u64) -> Sweep {
|
||||
let Sweep::Hit { t, block, .. } = hit else { return hit };
|
||||
let k = (key, block);
|
||||
let Some(e) = self.rays.entries.get_mut(&k) else {
|
||||
if self.rays.entries.len() >= 4096 {
|
||||
let mut first = now;
|
||||
if let Some(e) = self.rays.entries.get_mut(&k) {
|
||||
if e.cube {
|
||||
return hit;
|
||||
}
|
||||
self.rays.next = self.rays.next.wrapping_add(1);
|
||||
let id = self.rays.next;
|
||||
self.rays.entries.insert(k, RayEntry { id, a, b, state: Exact::Pending, asked: now });
|
||||
self.rays.by_id.insert(id, k);
|
||||
self.rays.out.push((id, a, b));
|
||||
return Sweep::Wait { t };
|
||||
};
|
||||
if e.state == Exact::Pending && now.saturating_sub(e.asked) > RAY_WAIT {
|
||||
e.state = Exact::Hit { point: a + (b - a) * t, normal: [0; 3], block };
|
||||
}
|
||||
match e.state {
|
||||
Exact::Pending => Sweep::Wait { t },
|
||||
Exact::Clear => Sweep::Clear,
|
||||
Exact::Hit { point, normal, block } => {
|
||||
let d = b - a;
|
||||
let f = ((point - a).dot(d) / d.length_squared().max(1e-12)).max(0.0);
|
||||
if f > 1.0 {
|
||||
// The shape's face is beyond this step: nothing is met yet.
|
||||
Sweep::Clear
|
||||
} else {
|
||||
Sweep::Hit { t: f, point, normal, block }
|
||||
// An answer is for the segment it was asked of: it serves this step only if the step lies on it.
|
||||
if let Some((sa, sb)) = within(e.a, e.b, a, b) {
|
||||
match e.state {
|
||||
Exact::Pending => {
|
||||
if now.saturating_sub(e.first) > RAY_WAIT {
|
||||
e.cube = true;
|
||||
return hit;
|
||||
}
|
||||
return Sweep::Wait { t };
|
||||
}
|
||||
Exact::Clear => return Sweep::Clear,
|
||||
Exact::Hit { point, normal, block } => {
|
||||
let d0 = e.b - e.a;
|
||||
let f_old = ((point - e.a).dot(d0) / d0.length_squared().max(1e-12)).clamp(0.0, 1.0);
|
||||
let f = (f_old - sa) / (sb - sa).max(1e-12);
|
||||
if f > 1.0 {
|
||||
// The shape's face is beyond this step: nothing is met yet.
|
||||
return Sweep::Clear;
|
||||
}
|
||||
let f = f.max(0.0);
|
||||
return Sweep::Hit { t: f, point: a + (b - a) * f, normal, block };
|
||||
}
|
||||
}
|
||||
}
|
||||
// A different step: the old answer says nothing of it. Ask again; an unanswered wait keeps its clock.
|
||||
if e.state == Exact::Pending {
|
||||
first = e.first;
|
||||
if now.saturating_sub(first) > RAY_WAIT {
|
||||
e.cube = true;
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
let old = e.id;
|
||||
self.rays.by_id.remove(&old);
|
||||
self.rays.out.retain(|r| r.0 != old);
|
||||
} else if self.rays.entries.len() >= 4096 {
|
||||
return hit;
|
||||
}
|
||||
self.rays.next = self.rays.next.wrapping_add(1);
|
||||
let id = self.rays.next;
|
||||
// Asked over a longer stretch than the step: a flight held at the face tries again from there with a
|
||||
// step of about the same line, which the answer then covers. What lies beyond the step is mapped back
|
||||
// when it is used (a face past the step is no contact).
|
||||
let far = a + (b - a) * RAY_REACH;
|
||||
self.rays.entries.insert(k, RayEntry { id, a, b: far, state: Exact::Pending, asked: now, first, cube: false });
|
||||
self.rays.by_id.insert(id, k);
|
||||
self.rays.out.push((id, a, far));
|
||||
Sweep::Wait { t }
|
||||
}
|
||||
|
||||
/// Forgets the exact answers (and questions) for blocks where `gone` says the geometry may have changed:
|
||||
/// a late reply to a forgotten question is dropped, the next step asks anew (#73).
|
||||
fn drop_rays(&mut self, gone: impl Fn(&[i32; 3]) -> bool) {
|
||||
let dead: Vec<(u32, [i32; 3])> = self.rays.entries.keys().filter(|k| gone(&k.1)).copied().collect();
|
||||
for k in dead {
|
||||
if let Some(e) = self.rays.entries.remove(&k) {
|
||||
self.rays.by_id.remove(&e.id);
|
||||
self.rays.out.retain(|r| r.0 != e.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,12 +584,16 @@ impl WorldCache {
|
||||
for r in replies {
|
||||
let Some(k) = self.rays.by_id.get(&r.id).copied() else { continue };
|
||||
let Some(e) = self.rays.entries.get_mut(&k) else { continue };
|
||||
if e.cube {
|
||||
continue;
|
||||
}
|
||||
e.state = if r.clear {
|
||||
Exact::Clear
|
||||
} else if r.hit {
|
||||
Exact::Hit { point: e.a + (e.b - e.a) * r.fraction, normal: r.normal, block: r.block }
|
||||
} else {
|
||||
// No verdict: the solid cube the sections said.
|
||||
e.cube = true;
|
||||
Exact::Hit { point: e.a, normal: [0; 3], block: k.1 }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -113,6 +113,27 @@ impl WaveConfig {
|
||||
let aim = Aim::ALL.iter().position(|a| *a == self.aim).unwrap_or(0);
|
||||
format!("{} {approach} {} {aim} {}", counts.join(","), self.dist, self.preset)
|
||||
}
|
||||
/// Whether `w` are words `parse` would read whole: an error says which is wrong (#86).
|
||||
pub fn check(&self, w: &[&str]) -> Result<(), String> {
|
||||
if let Some(c) = w.first() {
|
||||
if c.split(',').take(KINDS.len()).any(|v| v.parse::<u32>().is_err()) {
|
||||
return Err(format!("число по типам: «{c}»"));
|
||||
}
|
||||
}
|
||||
if w.get(1).is_some_and(|v| v.parse::<usize>().ok().is_none_or(|i| i >= Approach::ALL.len())) {
|
||||
return Err(format!("подход: «{}»", w[1]));
|
||||
}
|
||||
if w.get(2).is_some_and(|v| v.parse::<f64>().ok().is_none_or(|d| !d.is_finite())) {
|
||||
return Err(format!("дальность: «{}»", w[2]));
|
||||
}
|
||||
if w.get(3).is_some_and(|v| v.parse::<usize>().ok().is_none_or(|i| i >= Aim::ALL.len())) {
|
||||
return Err(format!("цель налёта: «{}»", w[3]));
|
||||
}
|
||||
if w.get(4).is_some_and(|v| v.parse::<usize>().is_err()) {
|
||||
return Err(format!("набор: «{}»", w[4]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Reads `text`'s words; what does not parse keeps its value.
|
||||
pub fn parse(&mut self, w: &[&str]) {
|
||||
if let Some(c) = w.first() {
|
||||
|
||||
@@ -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).
|
||||
@@ -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 не менялся.
|
||||
|
||||
@@ -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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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
|
||||
|
||||
|
||||
@@ -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/36] 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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||