Compare commits

..

2 Commits

56 changed files with 6600 additions and 223 deletions
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/barge"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/launch_ramp"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/launcher_truck"
}
}
}
+3
View File
@@ -55,6 +55,9 @@
"block.airdef.radar_truck": "Шасси РЛС",
"block.airdef.depot": "Склад боеприпасов",
"block.airdef.tzm_truck": "Транспортно-заряжающая машина",
"block.airdef.barge": "Баржа",
"block.airdef.launch_ramp": "Рампа запуска дронов",
"block.airdef.launcher_truck": "Пусковая машина ракет",
"block.airdef.ew_truck": "Станция РЭБ",
"block.airdef.searchlight": "Прожектор",
"block.airdef.searchlight_head": "Голова прожектора",
+3
View File
@@ -55,6 +55,9 @@
"block.airdef.radar_truck": "Шасси РЛС",
"block.airdef.depot": "Склад боеприпасов",
"block.airdef.tzm_truck": "Транспортно-заряжающая машина",
"block.airdef.barge": "Баржа",
"block.airdef.launch_ramp": "Рампа запуска дронов",
"block.airdef.launcher_truck": "Пусковая машина ракет",
"block.airdef.ew_truck": "Станция РЭБ",
"block.airdef.searchlight": "Прожектор",
"block.airdef.searchlight_head": "Голова прожектора",
@@ -0,0 +1,896 @@
{
"ambientocclusion": false,
"textures": {
"dark_metal": "airdef:block/dark_metal",
"metal": "airdef:block/metal",
"ru_khaki_s31": "airdef:block/ru_khaki_s31",
"ru_khaki_s33": "airdef:block/ru_khaki_s33",
"particle": "airdef:block/dark_metal"
},
"elements": [
{
"from": [
-16.0,
0.0,
-16.0
],
"to": [
32.0,
3.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
3.0,
-16.0
],
"to": [
32.0,
4.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#metal"
}
}
},
{
"from": [
-15.0,
3.0,
-15.0
],
"to": [
31.0,
4.4,
31.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"south": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"east": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"west": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#ru_khaki_s33"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#ru_khaki_s33"
}
}
},
{
"from": [
-16.0,
4.0,
-16.0
],
"to": [
-14.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
30.0,
4.0,
-16.0
],
"to": [
32.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
4.0,
30.0
],
"to": [
32.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
4.0,
-16.0
],
"to": [
32.0,
6.0,
-14.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-12.0,
4.4,
-10.0
],
"to": [
-9.0,
6.5,
-7.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
-10.0
],
"to": [
28.0,
6.5,
-7.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
-12.0,
4.4,
8.0
],
"to": [
-9.0,
6.5,
11.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
8.0
],
"to": [
28.0,
6.5,
11.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
-12.0,
4.4,
26.0
],
"to": [
-9.0,
6.5,
29.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
26.0
],
"to": [
28.0,
6.5,
29.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
}
]
}
@@ -0,0 +1,709 @@
{
"ambientocclusion": false,
"textures": {
"dark_metal": "airdef:block/dark_metal",
"metal": "airdef:block/metal",
"ru_khaki": "airdef:block/ru_khaki",
"particle": "airdef:block/dark_metal"
},
"elements": [
{
"from": [
1.0,
0.0,
-6.0
],
"to": [
15.0,
2.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
14.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
14.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
14.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
14.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
6.0,
2.0,
0.0
],
"to": [
10.0,
4.0,
26.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
4.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
4.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
4.0,
16
],
"texture": "#metal"
}
}
},
{
"from": [
3.0,
9.0,
-14.0
],
"to": [
5.0,
11.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
}
},
"rotation": {
"angle": 22.5,
"axis": "x",
"origin": [
4.0,
10.0,
8.0
]
}
},
{
"from": [
11.0,
9.0,
-14.0
],
"to": [
13.0,
11.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
}
},
"rotation": {
"angle": 22.5,
"axis": "x",
"origin": [
12.0,
10.0,
8.0
]
}
},
{
"from": [
3.0,
0.0,
-1.0
],
"to": [
5.0,
12.0,
1.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
11.0,
0.0,
-1.0
],
"to": [
13.0,
12.0,
1.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
3.0,
0.0,
17.0
],
"to": [
5.0,
6.0,
19.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
11.0,
0.0,
17.0
],
"to": [
13.0,
6.0,
19.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
4.0,
10.0,
24.0
],
"to": [
12.0,
14.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
8.0,
4.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
8.0,
4.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
6.0,
4.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
6.0,
4.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
8.0,
6.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
8.0,
6.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
1.0,
2.0,
28.0
],
"to": [
15.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"south": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"east": {
"uv": [
0,
0,
4.0,
4.0
],
"texture": "#ru_khaki"
},
"west": {
"uv": [
0,
0,
4.0,
4.0
],
"texture": "#ru_khaki"
},
"up": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"down": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
}
}
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+190 -80
View File
@@ -243,21 +243,42 @@ fn placements<F: Fn(f64, f64) -> Rig>(mk: F) -> Vec<Rig> {
vec![mk(1.0, 0.0), mk(-1.0, 0.0), mk(1.0, 28.0), mk(-1.0, 28.0)]
}
/// The shots of event `i`.
fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
/// Shortest shot worth cutting to, in replay ticks.
const MIN_SHOT: f64 = 8.0;
/// The replay tick a shot ends at.
fn shot_end(s: &Shot) -> f64 {
s.table.last().copied().unwrap_or(0.0)
}
/// The shots of event `i`, with replay time never running back: no shot starts before `floor` (the
/// end of the one before it, or of the previous event's last), a stage the real intervals leave no
/// room for is merged into its neighbour or left out, and what happens in the story comes in the
/// order it happened (a launch before its kill; an interceptor not before its first point).
fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
let e = rec.events[i];
let te = e.at as f64;
let o = rec.origin;
let end = rec.len as f64;
let pos = e.pos;
let mut shots: Vec<Shot> = Vec::new();
let mut add = |shot: Shot| shots.push(shot);
let cur = std::cell::Cell::new(floor);
let mut add = |shot: Shot| {
cur.set(shot_end(&shot));
shots.push(shot);
};
// The interval a stage may use: not before the cursor, and long enough to be a shot.
let span = |a: f64, b: f64| -> Option<(f64, f64)> {
let a = a.max(0.0).max(cur.get());
(b - a >= MIN_SHOT).then_some((a, b))
};
// The slow moment: half speed into it, a tenth through it, back to normal after.
let slow = |lead: f64, after: f64| -> Vec<(f64, f64)> {
vec![(te - lead, 0.6), (te - 4.0, 0.15), (te + 6.0, 0.15), (te + 22.0, 0.6), (te + after, 1.0)]
};
let kill_close = |from: DVec3, aim_id: Option<u32>, head: &str, sub: String| -> Shot {
let a = (te - 10.0).max(0.0);
// Never before the story so far; held to at least a few ticks.
let a = (te - 10.0).max(0.0).max(cur.get());
let b = (te + 48.0).min(end).max(a + 4.0);
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
@@ -272,8 +293,7 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
Shot::new(a, b, &slow(10.0, 48.0), variants, head, &sub, i)
};
let aftermath = |from: DVec3, head: &str| -> Option<Shot> {
let a = (te + 48.0).min(end);
let b = (te + 150.0).min(end);
let (a, b) = span((te + 48.0).min(end), (te + 150.0).min(end))?;
if b - a < 20.0 {
return None;
}
@@ -295,60 +315,78 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
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 +398,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 +426,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 +440,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 +461,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 +619,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) },
});
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:?}");
}
}
+69 -16
View File
@@ -81,6 +81,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
@@ -130,11 +132,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,
@@ -426,13 +439,18 @@ impl AirDef {
/// The searchlights' spot lights (HD): a cone from the head along its real direction, as far
/// as the beam runs free; at most 16, the renderer draws the nearest.
fn spots(&self) -> Vec<SpotLight> {
if self.shown().replay {
return Vec::new();
}
self.sim
.sams
.iter()
.filter_map(|s| s.spot_beam())
let shown = self.shown();
// A replay lights with the recording's searchlights, where and as they pointed.
let beams: Vec<sim::lights::Beam> = if shown.replay {
match self.replay.as_ref().and_then(|r| self.sim.replay(r.rec).map(|rec| rec.beams(f64::from(shown.now)))) {
Some(b) => b,
None => Vec::new(),
}
} else {
self.sim.sams.iter().filter_map(|s| s.spot_beam()).collect()
};
beams
.into_iter()
.take(16)
.map(|b| SpotLight {
id: b.id,
@@ -536,13 +554,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)
}
@@ -962,6 +978,7 @@ impl AirDef {
/// Plays the last recorded raid: "map", "full", "reel" or (with `event`) a clip.
fn start_replay(&mut self, what: &str, event: Option<usize>) -> Result<(), String> {
switch_allowed(self.export.is_some())?;
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
@@ -980,7 +997,11 @@ impl AirDef {
_ => replay::Mode::Map,
};
self.say_block_log(rec);
self.replay = Some(replay::Player::new(rec.number, mode, &self.sim));
let player = replay::Player::new(rec.number, mode, &self.sim);
// A film (its quality and effects) or another replay ends properly before this one.
self.end_session();
self.session += 1;
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
Ok(())
@@ -991,6 +1012,12 @@ impl AirDef {
render::capture_stop(true);
say("Экспорт отменён");
}
self.end_session();
}
/// Ends whatever replay or film shows, on every path: the film quality, the effects, the
/// clean HUD, the views and the time and camera overrides go back to the live game's.
fn end_session(&mut self) {
render::set_film_quality(false);
if let Some((shader, hd)) = self.film_prev.take() {
self.set_effects(shader, hd);
@@ -1007,6 +1034,9 @@ impl AirDef {
/// Plays the director's film of the last raid at the best quality: HD effects on (restored after).
fn start_film(&mut self, which: &str, export: Option<&ExportOpts>) -> Result<(), String> {
if export.is_none() {
switch_allowed(self.export.is_some())?;
}
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
@@ -1025,6 +1055,9 @@ impl AirDef {
player.clean = true;
player.limit = o.seconds;
}
// What was shown ends first (its effects are remembered below, not lost).
self.end_session();
self.session += 1;
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
@@ -1068,13 +1101,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;
@@ -1349,6 +1389,7 @@ impl Mod for AirDef {
time_view: false,
export: None,
film_prev: None,
session: 0,
smoke: Default::default(),
far: true,
moving: None,
@@ -1913,3 +1954,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());
}
}
+37 -4
View File
@@ -13,6 +13,17 @@ use crate::sim::{Class, DroneKind, Out, Sim};
use glam::DVec3;
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape};
/// Metres the camera keeps above the surface (land or water) it stands over.
const SURFACE_CLEAR: f64 = 2.5;
/// `eye` lifted above the surface under it (`Ground::land_state`: unanswered or no land: unchanged).
fn above_surface(eye: DVec3, land: Option<Option<f64>>) -> DVec3 {
match land {
Some(Some(h)) if eye.y < h + SURFACE_CLEAR => DVec3::new(eye.x, h + SURFACE_CLEAR, eye.z),
_ => eye,
}
}
/// Ghost models' ids, clear of the live ones.
const GHOSTS: u32 = 0x5000_0000;
/// The camera view's id (a new one each cut, so the game doesn't sweep between shots).
@@ -419,7 +430,7 @@ impl Player {
return;
}
sim.draw_ghosts(rec, self.t, GHOSTS, out);
self.direct(rec);
self.direct(rec, ground);
self.sounds(rec, listener, out);
}
@@ -565,7 +576,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 +613,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 +645,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 +659,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 +972,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);
}
}
+7 -1
View File
@@ -12,6 +12,7 @@ use std::collections::HashMap;
mod damage;
pub mod fires;
mod raid;
pub mod sites;
pub mod record;
mod sight;
mod terrain;
@@ -1424,6 +1425,8 @@ pub struct Sim {
later: Vec<(u64, DVec3, u32, bool)>,
/// A planned raid's drones waiting for their launch tick.
pub queue: Vec<(u64, Drone)>,
/// The raid's launch sites and their jobs (#100).
pub sites: sites::Sites,
/// A raid being planned.
plan: Option<raid::Plan>,
/// Each marked object's health, 0 to 100 (`targets`' order).
@@ -1492,6 +1495,7 @@ impl Sim {
horizons: HashMap::new(),
later: Vec::new(),
queue: Vec::new(),
sites: sites::Sites::default(),
plan: None,
target_health: Vec::new(),
wave: None,
@@ -1597,6 +1601,7 @@ impl Sim {
self.fx.clear();
self.blasts.clear();
self.queue.clear();
self.sites.clear();
self.plan = None;
self.wave = None;
// Ammunition still to cook off, flares in the sky.
@@ -1611,7 +1616,7 @@ impl Sim {
self.tick += 1;
self.plan_step(Some(&mut *ground), out);
self.adapt();
self.release_queue();
self.release_queue(out);
self.step_drones(ground, player, out);
self.step_later(ground, player, out);
self.step_moves(ground, out);
@@ -1623,6 +1628,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);
+200 -14
View File
@@ -69,6 +69,9 @@ struct Item {
key: Option<(DroneKind, i32, i32, i32)>,
early: f64,
gap: f64,
/// The site it is a job of: the want (see `sites`) and its pad there.
want: usize,
pad: u32,
}
/// A raid being planned: its drones, the prices and routes so far, and the planned ones.
@@ -78,8 +81,12 @@ pub(super) struct Plan {
routes: HashMap<(DroneKind, i32, i32, i32), Vec<DVec3>>,
items: Vec<Item>,
next: usize,
/// (drone, flight time, how early it arrives, its wave's delay)
done: Vec<(Drone, f64, f64, f64)>,
/// (drone, flight time, how early it arrives, its wave's delay, its site)
done: Vec<(Drone, f64, f64, f64, u32)>,
/// The launch sites wanted, and whether they are settled; the ticks spent waiting for the game's answers.
wants: Vec<sites::Want>,
sited: bool,
site_wait: u32,
smart: bool,
approach: Approach,
total: u32,
@@ -242,7 +249,9 @@ impl Sim {
let smart = order.aim == Aim::Smart;
// A smart enemy comes from where its raids got through best.
let bearing = if smart { self.best_bearing() } else { self.rng.f() * TAU };
let second = bearing + std::f64::consts::PI * (0.6 + self.rng.f() * 0.6);
// The raid's sides (1, 2, or 4-5): its launch sites go there (#100).
let sides = sites::pick_sides(&mut self.rng, order.approach, bearing);
let mut wants: Vec<sites::Want> = Vec::new();
// One grid over the whole raid; each kind's prices are worked out once, routes are shared
// by the drones of a kind going from the same bearing to the same target.
let span = order.dist + 450.0;
@@ -252,25 +261,31 @@ impl Sim {
for (group, &(kind, count)) in order.kinds.iter().enumerate() {
let spec = kind.spec();
for _ in 0..count {
let a = match order.approach {
Approach::Sector | Approach::Waves => bearing + self.rng.s() * 0.35,
Approach::TwoSectors => (if self.rng.chance(0.5) { bearing } else { second }) + self.rng.s() * 0.3,
Approach::AllSides => self.rng.f() * TAU,
let side = match order.approach {
Approach::Sector | Approach::Waves => 0,
Approach::TwoSectors => usize::from(self.rng.chance(0.5)),
Approach::AllSides => (self.rng.f() * sides.len() as f64) as usize % sides.len(),
};
let a = sides[side];
let object = objects[(self.rng.f() * objects.len() as f64) as usize % objects.len()];
let (target, at_sam) = self.choose_target(kind, object, order.aim);
let aim = target + DVec3::new(self.rng.s() * 4.0, 0.0, self.rng.s() * 4.0);
let dist = spec.ballistic.map_or(order.dist, |far| far.max(order.dist)) + self.rng.f() * 50.0;
let height = if spec.ballistic.is_some() { 10.0 } else if spec.cruise > 0.0 { spec.cruise } else { 25.0 };
// Where it starts is provisional: the site's pad, once the site is placed.
let from = centre + DVec3::new(a.sin() * dist, height + self.rng.s() * 6.0, a.cos() * dist);
let mut d = self.make_drone(kind, from, horizontal(aim - from), aim);
let range = spec.ballistic.map_or(order.dist, |far| far.max(order.dist));
let want = sites::want_index(&mut wants, centre, side, a, sites::SiteClass::of(kind), range, order.dist.max(120.0));
let pad = wants[want].jobs;
wants[want].jobs += 1;
d.sector = ew::sector(a) as u8;
let routed = smart && spec.ballistic.is_none() && !spec.decoy && !at_sam.is_some_and(|_| kind == DroneKind::Lancet);
let key = routed.then(|| (kind, (a.rem_euclid(TAU) / 0.35) as i32, (target.x / 24.0) as i32, (target.z / 24.0) as i32));
// Decoys come 15 s ahead, the rest together; "waves" one kind after another.
let early = if spec.decoy { 300.0 } else { 0.0 };
let gap = if order.approach == Approach::Waves { group as f64 * 300.0 } else { 0.0 };
items.push(Item { d, from, aim, key, early, gap });
items.push(Item { d, from, aim, key, early, gap, want, pad });
}
}
self.plan = Some(Plan {
@@ -280,6 +295,9 @@ impl Sim {
items,
next: 0,
done: Vec::new(),
wants,
sited: false,
site_wait: 0,
smart,
approach: order.approach,
total,
@@ -297,6 +315,20 @@ impl Sim {
/// Works on the raid's plan a while (a tick's share), and launches it when it is ready.
pub(super) fn plan_step(&mut self, ground: Option<&mut Ground>, out: &mut Out) {
let Some(mut plan) = self.plan.take() else { return };
let mut ground = ground;
// First the launch sites: suitable ground the game has told of, or no raid (#100).
if !plan.sited {
let Some(g) = ground.as_deref_mut() else {
self.plan = Some(plan);
return;
};
if !self.settle_sites(&mut plan, g, out) {
if !plan.items.is_empty() {
self.plan = Some(plan);
}
return;
}
}
// A smart raid first learns the land under its grid: asked for in bulk, a thousand columns a tick, for
// up to two seconds (what the game has no land for stays unknown and costs nothing).
if !plan.mapped {
@@ -342,7 +374,7 @@ impl Sim {
let mut d = plan.items[i].d.clone();
// It starts over the land, not in it: a hill under the starting point lifts it.
let mut from = from;
if !plan.heights.is_empty() {
if !plan.heights.is_empty() && plan.wants[plan.items[i].want].found.is_none() {
let (gi, gj) = plan.grid.index(from);
let g = plan.heights[(gi * plan.grid.size + gj) as usize];
let floor = f64::from(g) + if spec.ballistic.is_some() { 10.0 } else { spec.clearance + 10.0 };
@@ -388,7 +420,7 @@ impl Sim {
Flight::Ballistic => (horizontal(aim - from).length() / spec.speed).max(30.0),
_ => path / spec.speed,
};
plan.done.push((d, time, plan.items[i].early, plan.items[i].gap));
plan.done.push((d, time, plan.items[i].early, plan.items[i].gap, plan.wants[plan.items[i].want].site));
plan.next += 1;
work += 1;
}
@@ -398,10 +430,13 @@ impl Sim {
}
// Timing: smart raids arrive at once; others leave at once (a moment apart).
let now = self.tick;
let arrive = plan.done.iter().map(|(_, t, e, g)| t + e - g).fold(0.0, f64::max);
for (mut d, time, early, gap) in plan.done {
let arrive = plan.done.iter().map(|(_, t, e, g, _)| t + e - g).fold(0.0, f64::max);
for (mut d, time, early, gap, site) in plan.done {
d.smart = plan.smart;
let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
if site != 0 {
self.sites.job(d.id, site);
}
self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d));
}
self.wave = Some(WaveStats {
@@ -575,17 +610,156 @@ impl Sim {
}
/// A planned raid's drones take off when their time comes.
pub(super) fn release_queue(&mut self) {
pub(super) fn release_queue(&mut self, out: &mut Out) {
let now = self.tick;
if self.queue.iter().all(|q| q.0 > now) {
return;
}
let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now);
self.queue = wait;
for (_, d) in go {
let mut cancelled = 0;
for (t, d) in go {
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
match self.site_slot(d.id) {
sites::Slot::Free => {}
sites::Slot::Busy => {
self.queue.push((t, d));
continue;
}
sites::Slot::Gone => {
cancelled += 1;
continue;
}
sites::Slot::Go => {
let dir = d.vel.normalize_or_zero();
out.sound(d.kind.launch_sound(), d.pos, 1.0, 1.0);
out.particles("minecraft:flame", d.pos - dir * 1.5, 0.15, -dir * 0.1, 0.02, 4);
out.particles("minecraft:large_smoke", d.pos - dir * 2.5, 0.5, DVec3::new(0.0, 0.05, 0.0), 0.02, 4);
}
}
self.drones.push(d);
self.launched += 1;
}
if cancelled > 0 {
out.messages.push(format!("Пусковая площадка не запустила {cancelled}: нет запаса или уничтожена"));
}
}
/// Looks for the raid's launch sites. Returns whether they are settled (placed, or given up: drones whose
/// side has no suitable ground go to another side's site, or are cancelled with a message; a raid left with
/// nothing is cancelled whole).
fn settle_sites(&mut self, plan: &mut Plan, ground: &mut Ground, out: &mut Out) -> bool {
plan.site_wait += 1;
let mut keep: Vec<DVec3> = self.sams.iter().filter(|s| s.alive()).map(|s| s.pos).collect();
keep.extend(self.targets.iter().copied());
keep.push(plan.centre);
let last = plan.site_wait >= sites::SEARCH_TICKS;
if !sites::search(&mut plan.wants, ground, plan.centre, &keep, self.tick, last) {
return false;
}
use sites::Search;
// A side that found nothing hands its drones to a site of the same class and range on another side.
let mut moved = 0;
let mut dropped = 0;
let found: Vec<usize> = (0..plan.wants.len()).filter(|&i| plan.wants[i].state == Search::Found).collect();
let mut keep_items = Vec::new();
for mut it in std::mem::take(&mut plan.items) {
let w = &plan.wants[it.want];
if w.state != Search::Found {
let (class, range) = (w.class, w.ordered);
let near = |x: &sites::Want| x.class == class && (x.ordered - range).abs() < range * 0.35 + 30.0;
match found.iter().copied().find(|&i| near(&plan.wants[i])) {
Some(i) => {
it.want = i;
moved += 1;
}
None => {
dropped += 1;
continue;
}
}
}
keep_items.push(it);
}
plan.items = keep_items;
if moved + dropped > 0 {
out.messages.push(format!(
"Нет пригодной земли для части площадок: {moved} целей ушли с другой стороны, {dropped} отменено"
));
}
plan.total -= dropped;
if plan.items.is_empty() {
out.messages.push("Налёт отменён: нет пригодной известной земли для пусковых площадок".into());
return false;
}
// The pads, and the sites themselves; the old raid's sites go unless they still have jobs to fly.
let queued: Vec<u32> = self.queue.iter().map(|q| q.1.id).collect();
self.sites.retire(&queued);
for w in plan.wants.iter_mut() {
w.jobs = 0;
}
for it in plan.items.iter_mut() {
it.pad = plan.wants[it.want].jobs;
plan.wants[it.want].jobs += 1;
}
let mut shortened = false;
for w in plan.wants.iter_mut().filter(|w| w.state == Search::Found && w.jobs > 0) {
let mut pos = w.found.unwrap_or_default();
if w.water {
pos.y += sites::DECK;
}
shortened |= w.range < w.ordered - if w.class == sites::SiteClass::Battery { 1.0 } else { 75.0 };
w.site = self.sites.add(sites::LaunchSite {
id: 0,
side: w.side as u8,
sector: ew::sector(w.bearing) as u8,
bearing: w.bearing,
class: w.class,
pos,
range: w.range,
water: w.water,
stock: w.jobs,
planned: w.jobs,
alive: true,
next_free: 0,
});
}
if shortened {
out.messages.push("Пуск ближе обычного: дальше нет известной земли или воды".into());
}
// Each drone onto its pad, climbing off the ramp or leaving the tube (ballistic ones aim as always).
for it in plan.items.iter_mut() {
let w = &plan.wants[it.want];
let Some(site) = self.sites.get(w.site) else { continue };
let from = site.muzzle(it.pad);
let spec = it.d.kind.spec();
it.from = from;
it.d.pos = from;
if spec.ballistic.is_some() {
let flat = horizontal(it.d.target - from);
let time = (flat.length() / spec.speed).max(30.0);
it.d.vel = flat / time + DVec3::Y * ((it.d.target.y - from.y + 0.5 * GRAVITY * time * time) / time);
} else {
let pitch = site.class.pitch();
let dir = horizontal(it.d.target - from).normalize_or(site.inward());
it.d.vel = (dir * pitch.cos() + DVec3::Y * pitch.sin()) * spec.speed;
}
it.d.sector = site.sector;
}
let n = self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)).count();
let barges = self.sites.list.iter().filter(|s| s.water && plan.wants.iter().any(|w| w.site == s.id)).count();
out.messages.push(format!("Пусковые площадки противника: {n}{}", if barges > 0 { format!(" (на баржах: {barges})") } else { String::new() }));
// Where they are is the enemy's secret, unless the sandbox's true picture is on.
if self.truth {
for site in self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)) {
out.messages.push(format!(
"Площадка #{} ({:?}, сторона {}): {:.0} {:.0} {:.0}, запас {}",
site.id, site.class, site.side, site.pos.x, site.pos.y, site.pos.z, site.stock
));
}
}
plan.sited = true;
true
}
/// When the raid is over: what it cost both sides.
@@ -632,6 +806,7 @@ fn route_length(from: DVec3, route: &[DVec3], to: DVec3) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
use super::sites::testland::{Land, serve};
#[test]
fn a_smart_raid_plans_and_flies_in_time() {
@@ -641,8 +816,10 @@ mod tests {
sim.sams.push(Sam::new(id, kind, DVec3::new(-30.0 + 15.0 * i as f64, 70.0, 0.0), 0.0));
}
let mut ground = Ground::default();
let land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..30 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let t = std::time::Instant::now();
@@ -655,6 +832,7 @@ mod tests {
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, -40.0)], &mut out);
let mut ticks = 0;
while sim.planning() {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
ticks += 1;
}
@@ -663,6 +841,7 @@ mod tests {
for k in 0..4000 {
let s = std::time::Instant::now();
out = Out::default();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
assert!(s.elapsed().as_millis() < 200, "tick {k} took {:?}", s.elapsed());
}
@@ -679,8 +858,10 @@ mod tests {
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(2.0 * i as f64, 70.0, 0.0), 0.0));
}
let mut ground = Ground::default();
let land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..30 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, -400.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
@@ -697,6 +878,7 @@ mod tests {
#[cfg(test)]
mod big {
use super::*;
use super::sites::testland::{Land, serve};
#[test]
fn sixty_one_systems_and_a_big_smart_raid() {
@@ -710,8 +892,10 @@ mod big {
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
sim.target_health.push(100.0);
let mut ground = Ground::default();
let land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..200 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let mut w = crate::wave::WaveConfig::default();
@@ -725,6 +909,7 @@ mod big {
let mut slowest = std::time::Duration::ZERO;
while sim.planning() {
let s = std::time::Instant::now();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
slowest = slowest.max(s.elapsed());
ticks += 1;
@@ -734,6 +919,7 @@ mod big {
for _ in 0..3000 {
let s = std::time::Instant::now();
out = Out::default();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
worst = worst.max(s.elapsed());
}
+85
View File
@@ -18,6 +18,8 @@ pub struct SamKey {
pitch: f32,
/// The antenna's turn.
dish: f32,
/// A searchlight's beam: how far it runs free (to 8 blocks), 0 when it is off.
beam: f32,
}
impl SamKey {
@@ -30,6 +32,7 @@ impl SamKey {
yaw: sam.yaw as f32,
pitch: sam.pitch as f32,
dish: sam.dish as f32,
beam: if sam.beam_on { ((sam.beam_free / 8.0).round() * 8.0).max(8.0) as f32 } else { 0.0 },
}
}
/// The same pose (to within what could be seen).
@@ -40,6 +43,7 @@ impl SamKey {
&& near(self.yaw, o.yaw, 0.002)
&& near(self.pitch, o.pitch, 0.002)
&& near(self.dish, o.dish, 0.002)
&& self.beam == o.beam
}
}
@@ -79,6 +83,7 @@ impl SamTrack {
yaw: turn(a.yaw, b.yaw, f),
pitch: l(a.pitch, b.pitch),
dish: turn(a.dish, b.dish, f),
beam: a.beam,
})
}
/// The system as it stood at `t`, as a ghost to draw with the ids from `id`.
@@ -89,11 +94,54 @@ impl SamTrack {
sam.yaw = k.yaw as f64;
sam.pitch = k.pitch as f64;
sam.dish = k.dish as f64;
sam.beam_on = k.beam > 0.0;
sam.beam_free = k.beam as f64;
sam.health = if self.lost.is_some_and(|l| t >= l as f64) { 0.0 } else { 1.0 };
Some(sam)
}
}
#[cfg(test)]
impl Recording {
/// A recording of `len` ticks with nothing in it (for the planner's tests).
pub(crate) fn blank(len: u32) -> Self {
Recording {
number: 1,
origin: DVec3::ZERO,
len,
tracks: Vec::new(),
index: HashMap::new(),
events: Vec::new(),
tally: Tally::default(),
sams: Vec::new(),
objects: Vec::new(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
}
}
/// A straight track from `from` to `to` over the ticks `first..=last` (every `STEP`).
pub(crate) fn straight(&mut self, id: u32, what: What, first: u32, last: u32, from: DVec3, to: DVec3) {
let n = ((last - first) / STEP).max(1) as usize;
let points = (0..=n).map(|k| {
let p = from.lerp(to, k as f64 / n as f64);
[p.x as f32, p.y as f32, p.z as f32]
});
self.index.insert(id, self.tracks.len());
self.tracks.push(Track { id, what, first, points: points.collect(), fell: None });
}
}
impl Recording {
/// The searchlights' beams at `t` as the recording had them (position and direction from the
/// recorded poses, none for a lost light), never the live state.
pub fn beams(&self, t: f64) -> Vec<lights::Beam> {
self.sam_tracks.iter().filter_map(|st| st.ghost(self.origin, t, st.id)?.spot_beam()).collect()
}
}
/// Ticks between a track's points.
pub const STEP: u32 = 2;
/// The longest recording (10 minutes) and how many are kept.
@@ -244,6 +292,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
@@ -587,6 +639,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) {
@@ -673,6 +732,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();
+66 -1
View File
@@ -654,6 +654,69 @@ fn replay_fires_and_blasts_follow_the_recording() {
assert!(record::replay_blasts(&rec, 10.0 + a[0].life as f64).is_empty(), "burnt out");
}
// #78: a replay's smoke is rebuilt from the recording as a function of its time: trails behind
// what flew (clearing the fog), a column over the fire, billows that outlive the fireball; a seek
// shows what playing through shows, and the live Sim is not touched.
#[test]
fn replay_smoke_has_trails_and_fire_columns_and_is_a_function_of_time() {
use record::{Event, EventKind};
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 2000.0));
sim.add_drone(DroneKind::Geran3, DVec3::new(30.0, 80.0, 0.0), DVec3::Z, DVec3::new(30.0, 70.0, 2000.0));
let mut g = Ground::default();
let mut out = Out::default();
for _ in 0..160 {
sim.step(&mut g, None, &mut out);
}
let mut rec = sim.rec.take().unwrap();
assert_eq!(rec.tracks.len(), 2);
rec.events = vec![Event { at: 40, pos: DVec3::new(5.0, 70.0, 5.0), kind: EventKind::Hit { drone: 1, kind: DroneKind::Geran3, power: 2, object: None, sam: None } }];
let live_tick = sim.tick;
let live_fires = sim.fires.list.len();
let t = 130.0;
let a = crate::smoke::replay_smoke(&rec, t);
let trails = a.iter().filter(|s| s.clear > 0.0).count();
let columns = a.iter().filter(|s| s.soot > 0.8).count();
let billows = a.iter().filter(|s| s.soot == 0.55).count();
assert!(trails > 0, "trails of both tracks: {trails}");
assert!(columns > 0, "a column over the fire: {columns}");
assert!(billows > 0, "the blast's billows");
// Seeking away and back, or forward from nothing, gives the same smoke.
let _ = crate::smoke::replay_smoke(&rec, 10.0);
let _ = crate::smoke::replay_smoke(&rec, 155.0);
let b = crate::smoke::replay_smoke(&rec, t);
assert_eq!(a.iter().map(|s| (s.id, s.born as u64)).collect::<Vec<_>>(), b.iter().map(|s| (s.id, s.born as u64)).collect::<Vec<_>>());
// Nothing from the future; before the raid, nothing.
assert!(a.iter().all(|s| s.born <= t));
assert!(crate::smoke::replay_smoke(&rec, -5.0).is_empty());
// The billows go on long after the short fireball is out.
let late = crate::smoke::replay_smoke(&rec, 400.0);
assert!(late.iter().any(|s| s.soot == 0.55));
// The live state is not touched.
assert_eq!((sim.tick, sim.fires.list.len()), (live_tick, live_fires));
}
// #76: the protected objects of a replay are the recording's, not the live marks.
#[test]
fn a_replay_draws_the_recorded_objects_not_the_live_ones() {
let mut sim = Sim::new(1);
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
sim.target_health.push(100.0);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
sim.step(&mut g, None, &mut out);
let rec = sim.rec.take().unwrap();
assert_eq!(rec.objects.len(), 1);
// The live mark is moved away after the raid; the replay still shows it where it was.
sim.targets[0] = DVec3::new(900.0, 70.0, 900.0);
let mut shown = Out::default();
sim.draw_ghosts(&rec, 0.0, 0x1000, &mut shown);
let marks: Vec<_> = shown.models.iter().filter(|m| m.name == "airdef:target").collect();
assert_eq!(marks.len(), 1);
assert!(marks[0].at.x.abs() < 1.0 && marks[0].at.z.abs() < 1.0, "{:?}", marks[0].at);
}
fn sim_first(r: &record::Recording) -> u64 {
r.tracks.first().map_or(0, |t| t.first as u64)
}
@@ -1285,11 +1348,13 @@ fn raids_have_the_same_strike_accuracy_as_manual_launches() {
sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0));
let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects };
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default());
// (The raid's launch sites need land the game has told of: a flat one is served.)
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
for _ in 0..40 {
if !sim.planning() {
break;
}
sim.step(&mut Ground::default(), None, &mut Out::default());
sites::testland::step(&mut sim, &mut ground, &land, &mut Out::default());
}
assert!(!sim.queue.is_empty() && sim.queue.iter().all(|(_, d)| d.err != DVec3::ZERO), "raid drones without a navigation error");
let mut d = sim.make_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, -500.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
+832
View File
@@ -0,0 +1,832 @@
//! Launch sites (#100): where a raid really starts from. A raid picks its sides (one, two, or four to five),
//! looks for suitable ground on each side (only land the game has told of: flat, open to the sky, clear in
//! front, away from the defence), and sets up a site there: a ramp for the drones, a launcher vehicle for the
//! cruise missiles, a battery for the ballistic ones (at their own range). The raid's drones are jobs of the
//! sites: each one leaves its site, one at a time, from the stock the site holds. Ground nobody has told of is
//! never suitable, and nothing is force-loaded: a side without suitable ground waits for the game's answers
//! for a few seconds, and is then given up with a message.
use super::*;
/// The most candidate places tried for one site, and how long (ticks) a raid waits for the game's answers.
pub const MAX_TRIES: usize = 90;
pub const SEARCH_TICKS: u32 = 300;
/// Candidates asked about ahead of the one being waited for.
const PREFETCH: usize = 3;
/// The footprint's samples (offsets in blocks), the most the ground may differ over it, and how far from
/// the defence and the objects a site must be.
const FOOTPRINT: [(f64, f64); 9] = [(0.0, 0.0), (8.0, 0.0), (-8.0, 0.0), (0.0, 8.0), (0.0, -8.0), (6.0, 6.0), (6.0, -6.0), (-6.0, 6.0), (-6.0, -6.0)];
const FLAT: i32 = 2;
const KEEP_OUT: f64 = 45.0;
/// What a site is for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SiteClass {
/// Drones: a catapult ramp.
Ramp,
/// Cruise and supersonic missiles: a launcher vehicle.
Launcher,
/// Ballistic missiles: a heavier launcher, far out.
Battery,
}
impl SiteClass {
pub fn of(kind: DroneKind) -> SiteClass {
let spec = kind.spec();
if spec.ballistic.is_some() {
SiteClass::Battery
} else if matches!(spec.class, Class::Drone | Class::Jet) {
SiteClass::Ramp
} else {
SiteClass::Launcher
}
}
/// Whether a site of this class can launch this kind.
pub fn supports(self, kind: DroneKind) -> bool {
SiteClass::of(kind) == self
}
/// Ticks between two launches from one site.
pub fn cadence(self) -> u64 {
match self {
SiteClass::Ramp => 3,
SiteClass::Launcher => 8,
SiteClass::Battery => 20,
}
}
fn slots(self) -> u32 {
match self {
SiteClass::Ramp => 4,
SiteClass::Launcher => 3,
SiteClass::Battery => 2,
}
}
fn spacing(self) -> f64 {
match self {
SiteClass::Ramp => 4.0,
SiteClass::Launcher => 9.0,
SiteClass::Battery => 12.0,
}
}
fn model(self) -> &'static str {
match self {
SiteClass::Ramp => "airdef:launch_ramp",
SiteClass::Launcher | SiteClass::Battery => "airdef:launcher_truck",
}
}
fn scale(self) -> f64 {
match self {
SiteClass::Ramp => 1.4,
SiteClass::Launcher => 1.5,
SiteClass::Battery => 1.9,
}
}
/// The barge's scale: its deck is 3 blocks a side at 1.
fn barge(self) -> f64 {
match self {
SiteClass::Ramp => 4.5,
_ => 7.0,
}
}
/// Where a drone leaves the kit: blocks ahead of its centre, and up from the ground.
fn muzzle(self) -> (f64, f64) {
match self {
SiteClass::Ramp => (1.8, 1.7),
SiteClass::Launcher => (2.6, 3.4),
SiteClass::Battery => (3.2, 4.2),
}
}
/// How steeply a drone leaves it (radians).
pub fn pitch(self) -> f64 {
match self {
SiteClass::Ramp => 0.35,
SiteClass::Launcher => 0.6,
SiteClass::Battery => 0.9,
}
}
}
/// One launch site of a raid: stable id, side, where, what it can launch, what is left, and when it may launch next.
#[derive(Clone, Debug)]
pub struct LaunchSite {
pub id: u32,
/// Which side of the raid it is on (0-4) and the enemy's sector of it.
pub side: u8,
pub sector: u8,
pub bearing: f64,
pub class: SiteClass,
/// The ground at its centre (the top of the surface), and how far from the raid's centre it is.
pub pos: DVec3,
pub range: f64,
/// On a barge over open water (its `pos` is the water's surface plus the deck).
pub water: bool,
/// Rounds left, and the jobs given to it (also how many pads are drawn).
pub stock: u32,
pub planned: u32,
pub alive: bool,
pub next_free: u64,
}
impl LaunchSite {
/// The direction a launch goes: from the site in, towards the raid's centre.
pub fn inward(&self) -> DVec3 {
DVec3::new(-self.bearing.sin(), 0.0, -self.bearing.cos())
}
fn lateral(&self) -> DVec3 {
DVec3::new(self.bearing.cos(), 0.0, -self.bearing.sin())
}
/// The base of pad `i` on the ground.
pub fn pad(&self, i: u32) -> DVec3 {
let n = self.class.slots();
self.pos + self.lateral() * ((f64::from(i % n) - f64::from(n - 1) / 2.0) * self.class.spacing())
}
/// Where a drone of pad `i` leaves the kit.
pub fn muzzle(&self, i: u32) -> DVec3 {
let (ahead, up) = self.class.muzzle();
self.pad(i) + self.inward() * ahead + DVec3::Y * up
}
pub fn supports(&self, kind: DroneKind) -> bool {
self.class.supports(kind)
}
}
/// The sites in play and the launch jobs given to them: (drone id, site id) for each drone still in the queue.
#[derive(Default)]
pub struct Sites {
pub list: Vec<LaunchSite>,
next: u32,
jobs: Vec<(u32, u32)>,
}
/// What a site says about a drone due to leave.
pub(super) enum Slot {
/// Not a site's job (a manual launch, an old save): off it goes.
Free,
Go,
/// The site launched a moment ago.
Busy,
/// The site is gone or empty: the job is cancelled.
Gone,
}
impl Sites {
pub fn get(&self, id: u32) -> Option<&LaunchSite> {
self.list.iter().find(|s| s.id == id)
}
/// Jobs waiting for a site.
pub fn pending(&self, id: u32) -> usize {
self.jobs.iter().filter(|j| j.1 == id).count()
}
/// The site a queued drone is a job of.
pub fn site_of(&self, drone: u32) -> Option<u32> {
self.jobs.iter().find(|j| j.0 == drone).map(|j| j.1)
}
pub fn clear(&mut self) {
self.list.clear();
self.jobs.clear();
}
pub(super) fn add(&mut self, mut site: LaunchSite) -> u32 {
self.next += 1;
site.id = self.next;
let id = site.id;
self.list.push(site);
id
}
pub(super) fn job(&mut self, drone: u32, site: u32) {
self.jobs.push((drone, site));
}
/// The old raid's sites go, but not those with jobs still to fly.
pub(super) fn retire(&mut self, queued: &[u32]) {
self.jobs.retain(|j| queued.contains(&j.0));
let jobs = &self.jobs;
self.list.retain(|s| jobs.iter().any(|j| j.1 == s.id));
}
fn take(&mut self, drone: u32, now: u64) -> Slot {
let Some(j) = self.jobs.iter().position(|j| j.0 == drone) else { return Slot::Free };
let site = self.jobs[j].1;
let Some(s) = self.list.iter_mut().find(|s| s.id == site) else {
self.jobs.swap_remove(j);
return Slot::Gone;
};
if !s.alive || s.stock == 0 {
self.jobs.swap_remove(j);
return Slot::Gone;
}
if s.next_free > now {
return Slot::Busy;
}
s.stock -= 1;
s.next_free = now + s.class.cadence();
self.jobs.swap_remove(j);
Slot::Go
}
}
// ---------------------------------------------------------------- choosing sides and places
/// The sides of a raid as bearings: one, two far apart (108° or more), or four to five spread round (at least
/// 43° apart). The seed decides which.
pub fn pick_sides(rng: &mut Rng, approach: Approach, bearing: f64) -> Vec<f64> {
match approach {
Approach::Sector | Approach::Waves => vec![bearing],
Approach::TwoSectors => vec![bearing, bearing + std::f64::consts::PI * (0.6 + rng.f() * 0.6)],
Approach::AllSides => {
let n = if rng.chance(0.5) { 4 } else { 5 };
let step = TAU / f64::from(n);
(0..n).map(|k| bearing + f64::from(k) * step + rng.s() * 0.2 * step).collect()
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Search {
Open,
Found,
Failed,
}
/// A site wanted by a raid: its side and class, the places to try in order, and how the search stands.
pub(super) struct Want {
pub side: usize,
pub bearing: f64,
pub class: SiteClass,
pub range: f64,
/// The range ordered (`range` is where the site stood in the end).
pub ordered: f64,
cands: Vec<(DVec3, bool)>,
cursor: usize,
pub found: Option<DVec3>,
pub water: bool,
pub state: Search,
pub jobs: u32,
pub site: u32,
}
/// The want of this side, class and range, made if there is none yet.
pub(super) fn want_index(wants: &mut Vec<Want>, centre: DVec3, side: usize, bearing: f64, class: SiteClass, range: f64, lo: f64) -> usize {
if let Some(i) = wants.iter().position(|w| w.side == side && w.class == class && (w.range - range).abs() < 1.0) {
return i;
}
let out = DVec3::new(bearing.sin(), 0.0, bearing.cos());
let lat = DVec3::new(bearing.cos(), 0.0, -bearing.sin());
// In order: known land at the ordered range (round it for most, the ordered range for a ballistic battery),
// known land nearer in steps, then a barge on open water at the ordered range, then on water nearer.
let near = |from: f64| -> Vec<f64> {
let min = if class == SiteClass::Battery { lo.min(from).max(80.0) } else { (from * 0.3).max(80.0) };
(1..=6).map(|i| from - (from - min) * f64::from(i) / 6.0).collect()
};
let round: Vec<f64> = if class == SiteClass::Battery {
vec![range]
} else {
[0.0, 24.0, -24.0, 48.0, -48.0, 72.0, -72.0].iter().map(|d| (range + d).max(KEEP_OUT)).collect()
};
let laterals: &[f64] = &[0.0, 20.0, -20.0];
let mut cands = Vec::new();
for water in [false, true] {
for r in round.iter().copied().chain(near(range)) {
for l in laterals {
cands.push((centre + out * r + lat * *l, water));
}
}
}
cands.truncate(MAX_TRIES);
wants.push(Want { side, bearing, class, range, ordered: range, cands, cursor: 0, found: None, water: false, state: Search::Open, jobs: 0, site: 0 });
wants.len() - 1
}
enum Verdict {
/// The game hasn't told enough yet.
Pending,
Bad,
/// Suitable: the surface at its centre, and whether it is water's.
Good(DVec3, bool),
}
/// Whether a site fits at `p` (x, z): every sample of its footprint is land the game told of and within
/// `FLAT` blocks of the others, the sky above is open, the way ahead is clear, and nothing of the defence is near.
fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64) -> Verdict {
if p.x.abs() > 29_000_000.0 || p.z.abs() > 29_000_000.0 || keep.iter().any(|k| horizontal(*k - p).length() < KEEP_OUT) {
return Verdict::Bad;
}
let (mut lo, mut hi, mut pending) = (i32::MAX, i32::MIN, false);
for (dx, dz) in FOOTPRINT {
match ground.column((p.x + dx).floor() as i32, (p.z + dz).floor() as i32, 256) {
None => pending = true,
// No land to tell of (the sea, the void): never a site.
Some(None) => return Verdict::Bad,
Some(Some(h)) => {
lo = lo.min(h as i32);
hi = hi.max(h as i32);
}
}
}
if pending {
return Verdict::Pending;
}
if hi - lo > FLAT {
return Verdict::Bad;
}
let top = f64::from(hi);
let at = DVec3::new(p.x.floor() + 0.5, top, p.z.floor() + 0.5);
// The game's height is the top of what blocks motion *or holds a fluid*: the block under the surface is
// solid at the footprint's centre and edges (land), or at none of them (water); a shore is neither.
let mut solid = 0;
for (dx, dz) in FOOTPRINT.iter().take(5) {
let c = DVec3::new((p.x + dx).floor() + 0.5, top, (p.z + dz).floor() + 0.5);
match ground.world.sweep(c + DVec3::Y * 0.5, c - DVec3::Y * 0.5, false, now) {
world::Sweep::Hit { .. } => solid += 1,
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { .. } => return Verdict::Bad,
}
}
if solid != 0 && solid != 5 {
return Verdict::Bad;
}
let up = ground.world.sweep(at + DVec3::Y * 30.0, at + DVec3::Y * 1.0, false, now);
let ahead = ground.world.sweep(at + DVec3::Y * 3.0, at + inward * 14.0 + DVec3::Y * 3.0, false, now);
for s in [up, ahead] {
match s {
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { .. } | world::Sweep::Hit { .. } => return Verdict::Bad,
}
}
Verdict::Good(at, solid == 0)
}
/// Works on the open wants: each tries its places in order, a place the game hasn't told enough of holds the
/// search (the next few are asked about meanwhile), and a bad place is passed over. After `last` nothing waits.
/// Returns whether every want is settled.
pub(super) fn search(wants: &mut [Want], ground: &mut Ground, centre: DVec3, keep: &[DVec3], now: u64, last: bool) -> bool {
let mut all = true;
for k in 0..wants.len() {
if wants[k].state != Search::Open {
continue;
}
// Two sites never share a place: the others' kit is in the way.
let mut keep: Vec<DVec3> = keep.to_vec();
keep.extend(wants.iter().filter_map(|o| o.found));
let w = &mut wants[k];
let inward = DVec3::new(-w.bearing.sin(), 0.0, -w.bearing.cos());
loop {
if w.cursor >= w.cands.len() {
w.state = Search::Failed;
break;
}
let (cand, water) = w.cands[w.cursor];
match judge(ground, cand, inward, &keep, now) {
Verdict::Bad => w.cursor += 1,
// Land when land was asked for, a barge when water was.
Verdict::Good(_, got) if got != water => w.cursor += 1,
Verdict::Good(at, _) => {
w.found = Some(at);
w.water = water;
w.range = horizontal(at - centre).length();
w.state = Search::Found;
break;
}
Verdict::Pending => {
for k in 1..=PREFETCH {
if let Some(&(c, _)) = w.cands.get(w.cursor + k) {
let _ = judge(ground, c, inward, &keep, now);
}
}
if last {
w.state = Search::Failed;
} else {
all = false;
}
break;
}
}
}
}
all
}
// ---------------------------------------------------------------- in the sim
/// Id base of the kit's models.
const KIT: u32 = 0x7400_0000;
/// The barge's deck over the water.
pub const DECK: f64 = 0.3;
impl Sim {
/// Takes site `id` out of play (destroyed): its stock is gone and its queued jobs are cancelled. Returns how
/// many. (The counter-strike, #101, uses this.)
pub fn destroy_site(&mut self, id: u32) -> u32 {
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id) else { return 0 };
s.alive = false;
s.stock = 0;
let ids: Vec<u32> = self.sites.jobs.iter().filter(|j| j.1 == id).map(|j| j.0).collect();
self.queue.retain(|q| !ids.contains(&q.1.id));
self.sites.jobs.retain(|j| j.1 != id);
ids.len() as u32
}
/// Asks a site whether a queued drone may leave now.
pub(super) fn site_slot(&mut self, drone: u32) -> Slot {
let now = self.tick;
self.sites.take(drone, now)
}
/// Draws every site's kit: a ramp or launcher per pad in use, turned to the way the launches go.
pub(super) fn draw_sites(&self, out: &mut Out) {
for s in self.sites.list.iter().filter(|s| s.alive) {
let rot = DQuat::from_rotation_y(heading_of(s.inward()));
let sc = s.class.scale();
if s.water {
let b = s.class.barge();
out.model(KIT + (s.id * 8 + 7) * PARTS, "airdef:barge", s.pos - DVec3::Y * (DECK + 0.8 - 0.5 * b), rot, b, false);
}
for i in 0..s.planned.min(s.class.slots()) {
let id = KIT + (s.id * 8 + i) * PARTS;
out.model(id, s.class.model(), s.pad(i) + DVec3::Y * 0.5 * sc, rot, sc, false);
}
}
}
}
#[cfg(test)]
pub(super) mod testland {
use super::*;
/// Land from a height function; blocks below it are solid, and the game's height map says the same.
pub struct Land<F: Fn(i32, i32) -> i32>(pub F);
impl<F: Fn(i32, i32) -> i32> world::WorldSource for Land<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < (self.0)(x, z))
}
}
/// What the game answers a tick after the sim asked: heights, and the sections.
pub fn serve<F: Fn(i32, i32) -> i32>(ground: &mut Ground, land: &Land<F>, now: u64) {
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (land.0)(x, z))).collect();
ground.answer(columns, now);
let sections = ground.world.take_wanted(now);
ground.world.serve(land, sections, now);
}
pub fn step<F: Fn(i32, i32) -> i32>(sim: &mut Sim, ground: &mut Ground, land: &Land<F>, out: &mut Out) {
serve(ground, land, sim.tick);
sim.step(ground, None, out);
}
}
#[cfg(test)]
mod tests {
use super::testland::*;
use super::*;
fn sep(a: f64, b: f64) -> f64 {
angle_diff(a, b).abs().to_degrees()
}
fn min_sep(sides: &[f64]) -> f64 {
let mut m = 360.0f64;
for i in 0..sides.len() {
for j in 0..i {
m = m.min(sep(sides[i], sides[j]));
}
}
m
}
#[test]
fn sector_modes_give_one_two_or_four_to_five_sides_apart() {
let mut fours = 0;
let mut fives = 0;
for seed in 1..200u64 {
for approach in Approach::ALL {
let mut rng = Rng::new(seed);
let b = rng.f() * TAU;
let sides = pick_sides(&mut rng, approach, b);
match approach {
Approach::Sector | Approach::Waves => assert_eq!(sides.len(), 1),
Approach::TwoSectors => {
assert_eq!(sides.len(), 2);
assert!(min_sep(&sides) >= 100.0, "two sectors only {:.0} deg apart", min_sep(&sides));
}
Approach::AllSides => {
assert!((4..=5).contains(&sides.len()), "{} sides", sides.len());
assert!(min_sep(&sides) >= 40.0, "all sides: two only {:.0} deg apart", min_sep(&sides));
if sides.len() == 4 {
fours += 1;
} else {
fives += 1;
}
}
}
}
}
assert!(fours > 20 && fives > 20, "the seed should pick both 4 and 5: {fours} / {fives}");
}
fn flat() -> Land<impl Fn(i32, i32) -> i32> {
Land(|_, _| 64)
}
fn order(approach: Approach, dist: f64) -> RaidOrder {
RaidOrder {
kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Gerbera, 3), (DroneKind::Kh101, 3), (DroneKind::Iskander, 2)],
approach,
dist,
aim: Aim::Objects,
}
}
/// Orders a raid over `land` and plans it to the end; returns the ticks it took.
fn plan<F: Fn(i32, i32) -> i32>(sim: &mut Sim, ground: &mut Ground, land: &Land<F>, o: &RaidOrder, out: &mut Out) -> u32 {
sim.order_raid(o, &[DVec3::new(0.0, 70.0, 0.0)], out);
let mut n = 0;
while sim.planning() {
step(sim, ground, land, out);
n += 1;
assert!(n < 1000, "the raid never finished planning");
}
n
}
#[test]
fn sites_per_sector_mode_launch_every_drone_from_a_site_on_a_side() {
for (approach, sides) in [(Approach::Sector, 1usize), (Approach::TwoSectors, 2), (Approach::AllSides, 5), (Approach::Waves, 1)] {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(21);
plan(&mut sim, &mut ground, &land, &order(approach, 300.0), &mut out);
let mut sides_used: Vec<u8> = sim.sites.list.iter().map(|s| s.side).collect();
sides_used.sort_unstable();
sides_used.dedup();
match approach {
Approach::AllSides => assert!((4..=5).contains(&sides_used.len()), "{} sides", sides_used.len()),
_ => assert_eq!(sides_used.len(), sides, "{approach:?}"),
}
// No hidden extra position: at most a site per class on a side (ramp, launcher, one battery per range).
assert!(sim.sites.list.len() <= sides_used.len() * 3, "{} sites", sim.sites.list.len());
// Every drone has a site that supports its kind.
assert_eq!(sim.queue.len() + sim.drones.len(), 14, "{:?}", out.messages);
for (_, d) in &sim.queue {
let site = sim.sites.site_of(d.id).and_then(|id| sim.sites.get(id)).expect("a drone without a source site");
assert!(site.supports(d.kind), "{:?} from a {:?}", d.kind, site.class);
// It waits on the site's pad, over its ground.
assert!(horizontal(d.pos - site.pos).length() < 25.0, "{:?} is {:.0} away from its site", d.kind, horizontal(d.pos - site.pos).length());
assert!(d.pos.y > site.pos.y && d.pos.y < site.pos.y + 6.0);
}
for (i, a) in sim.sites.list.iter().enumerate() {
for b in &sim.sites.list[..i] {
assert!(horizontal(a.pos - b.pos).length() >= KEEP_OUT, "two sites on one place");
}
}
// Stock and jobs agree; ground is real: flat at 64.
for s in &sim.sites.list {
assert_eq!(s.stock as usize, sim.sites.pending(s.id));
assert_eq!(s.pos.y, 64.0);
}
}
}
#[test]
fn ballistic_sites_keep_their_own_range_and_ramps_the_ordered_one() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(4);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
let centre = DVec3::new(0.0, 70.0, 0.0);
for s in &sim.sites.list {
let r = horizontal(s.pos - centre).length();
match s.class {
SiteClass::Battery => assert!(r > 990.0, "a battery only {r:.0} out"),
_ => assert!((200.0..=400.0).contains(&r), "{:?} {r:.0} out", s.class),
}
}
}
#[test]
fn launches_start_at_the_site_with_a_real_climb_and_in_turn() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(9);
plan(&mut sim, &mut ground, &land, &order(Approach::TwoSectors, 300.0), &mut out);
let jobs: HashMap<u32, u32> = sim.queue.iter().map(|(_, d)| (d.id, sim.sites.site_of(d.id).unwrap())).collect();
// One or two may have left in the tick the plan was ready.
let early = sim.drones.len();
let stock: u32 = sim.sites.list.iter().map(|s| s.stock).sum();
assert_eq!(stock as usize + early, 14);
let mut seen = HashMap::new();
let mut last_launch: HashMap<u32, u64> = HashMap::new();
for _ in 0..4000 {
step(&mut sim, &mut ground, &land, &mut out);
for d in sim.drones.iter().filter(|d| d.age == 1) {
let site = sim.sites.get(jobs[&d.id]).unwrap().clone();
// Its first tick is at the kit, not in the air somewhere.
assert!(horizontal(d.pos - site.pos).length() < 25.0, "{:?} first seen {:.0} from its site", d.kind, horizontal(d.pos - site.pos).length());
assert!(d.pos.y - site.pos.y < 12.0, "{:?} first seen {:.1} above the ground", d.kind, d.pos.y - site.pos.y);
if d.kind.spec().ballistic.is_none() {
assert!(d.vel.y > 0.0, "{:?} leaves level, not climbing", d.kind);
}
// One launch at a time from a site.
if let Some(t) = last_launch.insert(site.id, sim.tick) {
assert!(sim.tick - t >= site.class.cadence(), "two launches {} ticks apart", sim.tick - t);
}
seen.insert(d.id, d.pos.y);
}
}
assert_eq!(seen.len() + early, 14, "not every drone left a site");
assert_eq!(sim.sites.list.iter().map(|s| s.stock).sum::<u32>(), 0, "stock left after the raid");
// The climb is real: an early drone is higher a second later, and clear of the ground.
assert!(sim.queue.is_empty());
}
#[test]
fn a_site_launches_only_what_it_holds() {
let mut sim = Sim::new(2);
let mut ground = Ground::default();
let mut out = Out::default();
let id = sim.sites.add(LaunchSite {
id: 0,
side: 0,
sector: 0,
bearing: 0.0,
class: SiteClass::Ramp,
pos: DVec3::new(0.0, 64.0, 300.0),
range: 300.0,
water: false,
stock: 3,
planned: 5,
alive: true,
next_free: 0,
});
for i in 0..5 {
let site = sim.sites.get(id).unwrap().clone();
let d = sim.make_drone(DroneKind::Shahed, site.muzzle(i), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
sim.sites.job(d.id, id);
sim.queue.push((1, d));
}
let mut times = Vec::new();
for _ in 0..80 {
let before = sim.launched;
sim.step(&mut ground, None, &mut out);
if sim.launched > before {
times.push(sim.tick);
}
}
assert_eq!(sim.launched, 3, "launched {:?}", times);
assert!(sim.queue.is_empty(), "the unlaunchable jobs stay in the queue");
assert_eq!(sim.sites.get(id).unwrap().stock, 0);
assert!(times.windows(2).all(|w| w[1] - w[0] >= SiteClass::Ramp.cadence()), "{times:?}");
}
#[test]
fn a_destroyed_site_cancels_its_future_jobs() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(5);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
let ramp = sim.sites.list.iter().find(|s| s.class == SiteClass::Ramp).unwrap().id;
let early = sim.launched as usize;
let n = sim.sites.pending(ramp);
assert!(n >= 8 && n <= 9, "{n}");
assert_eq!(sim.destroy_site(ramp) as usize, n);
assert_eq!(sim.queue.len() + early + n, 14);
for _ in 0..3000 {
step(&mut sim, &mut ground, &land, &mut out);
}
assert_eq!(sim.launched as usize + n, 14);
}
#[test]
fn no_suitable_ground_means_a_clear_refusal() {
// Never answered: unknown ground is not suitable, and nothing is launched.
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(3);
sim.order_raid(&order(Approach::TwoSectors, 300.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
let mut n = 0;
while sim.planning() {
sim.step(&mut ground, None, &mut out);
n += 1;
assert!(n <= SEARCH_TICKS + 5, "a raid waits for ever");
}
assert!(sim.queue.is_empty() && sim.sites.list.is_empty());
assert!(out.messages.iter().any(|m| m.contains("площад")), "{:?}", out.messages);
assert!(!sim.drones.iter().any(|d| d.alive()));
// A cliff face everywhere (too steep to stand on) is refused as well, and a sea (no land) too.
for steep in [true, false] {
let land = Land(move |x, _| if steep { x * 4 } else { -100 });
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(3);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
assert!(sim.queue.is_empty() && sim.sites.list.is_empty(), "steep {steep}");
assert!(out.messages.iter().any(|m| m.contains("площад")));
}
}
/// Land inside a radius of the origin (surface 64), open water outside it (surface 63 over a floor at 50):
/// the game's height over water is the surface, and the blocks under it are not solid.
struct Island(f64);
impl Island {
fn land(&self, x: i32, z: i32) -> bool {
f64::from(x).hypot(f64::from(z)) < self.0
}
}
impl world::WorldSource for Island {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < if self.land(x, z) { 64 } else { 50 })
}
}
fn run_island(radius: f64, seed: u64, o: &RaidOrder) -> (Sim, Out) {
let (mut ground, mut out, island) = (Ground::default(), Out::default(), Island(radius));
let mut sim = Sim::new(seed);
sim.order_raid(o, &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
for _ in 0..SEARCH_TICKS + 5 {
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, if island.land(x, z) { 64 } else { 63 })).collect();
ground.answer(cols, sim.tick);
let secs = ground.world.take_wanted(sim.tick);
ground.world.serve(&island, secs, sim.tick);
sim.step(&mut ground, None, &mut out);
}
for _ in 0..3000 {
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, if island.land(x, z) { 64 } else { 63 })).collect();
ground.answer(cols, sim.tick);
let secs = ground.world.take_wanted(sim.tick);
ground.world.serve(&island, secs, sim.tick);
sim.step(&mut ground, None, &mut out);
}
(sim, out)
}
#[test]
fn an_all_water_map_gives_barges_and_every_drone_launches() {
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Kh101, 3)], approach: Approach::TwoSectors, dist: 200.0, aim: Aim::Objects };
// Water from the first block: nothing but the sea round the raid (its centre is the player's slab, not read).
let (sim, out) = run_island(0.0, 6, &o);
assert!(!sim.sites.list.is_empty(), "no barge: {:?}", out.messages);
assert!(sim.sites.list.iter().all(|s| s.water && s.pos.y > 63.0 && s.pos.y < 64.0), "a site that is not on the surface");
assert_eq!(sim.launched, 9, "{:?}", out.messages);
let mut o2 = Out::default();
sim.draw_sites(&mut o2);
assert!(o2.models.iter().any(|m| m.name == "airdef:barge"));
}
#[test]
fn land_is_preferred_over_water_when_both_are_known() {
// An island of radius 260 in the sea; the raid is ordered 300 out: its sites stand on the island, nearer.
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Kh101, 3)], approach: Approach::TwoSectors, dist: 300.0, aim: Aim::Objects };
let (sim, out) = run_island(260.0, 6, &o);
assert!(!sim.sites.list.is_empty());
assert!(sim.sites.list.iter().all(|s| !s.water), "a barge where land was known: {:?} {:?}", out.messages, sim.sites.list.iter().map(|s| (s.class, s.water, s.pos, s.range)).collect::<Vec<_>>());
assert_eq!(sim.launched, 9);
}
#[test]
fn a_site_never_stands_in_a_hill_or_under_a_roof() {
// A hill 40 blocks high round the raid's ring at 300 out (radius 100): the sites find flat land elsewhere.
let hill = |x: i32, z: i32| {
let (dx, dz) = (f64::from(x) - 0.0, f64::from(z) - 300.0);
if dx * dx + dz * dz < 100.0 * 100.0 { 64 + 40 } else { 64 }
};
let land = Land(hill);
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(8);
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 5)], approach: Approach::Sector, dist: 300.0, aim: Aim::Objects };
plan(&mut sim, &mut ground, &land, &o, &mut out);
for s in &sim.sites.list {
for (dx, dz) in FOOTPRINT {
let h = hill((s.pos.x + dx).floor() as i32, (s.pos.z + dz).floor() as i32);
assert!((f64::from(h) - s.pos.y).abs() <= f64::from(FLAT), "a site over a slope");
}
}
assert!(!sim.sites.list.is_empty());
}
#[test]
fn repeated_raids_do_not_pile_up_sites_and_clear_removes_them() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(12);
plan(&mut sim, &mut ground, &land, &order(Approach::AllSides, 300.0), &mut out);
let first: Vec<u32> = sim.sites.list.iter().map(|s| s.id).collect();
for _ in 0..3000 {
step(&mut sim, &mut ground, &land, &mut out);
}
plan(&mut sim, &mut ground, &land, &order(Approach::TwoSectors, 300.0), &mut out);
assert!(sim.sites.list.iter().all(|s| !first.contains(&s.id)), "old sites stayed");
assert!(sim.sites.list.len() <= 6);
sim.clear();
assert!(sim.sites.list.is_empty() && sim.queue.is_empty());
let mut o = Out::default();
sim.draw_sites(&mut o);
assert!(o.models.is_empty());
}
#[test]
fn the_same_seed_picks_the_same_sites_and_graphics_do_not_move_them() {
let run = |draw: bool| {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(33);
plan(&mut sim, &mut ground, &land, &order(Approach::AllSides, 300.0), &mut out);
if draw {
// Rendering must not spend the combat randomness.
let mut o = Out::default();
sim.draw_sites(&mut o);
assert!(!o.models.is_empty());
}
(sim.sites.list.iter().map(|s| (s.id, s.pos.x.to_bits(), s.pos.z.to_bits(), s.stock)).collect::<Vec<_>>(), sim.queue.iter().map(|q| q.0).collect::<Vec<_>>(), sim.rng.state())
};
assert_eq!(run(false), run(true));
}
}
+148 -67
View File
@@ -47,6 +47,150 @@ fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment {
}
}
/// The column over one fire up to `tick`: a puff every 15 ticks (90 for the weaker fires) that
/// rises, spreads and thins. A pure function of the tick.
fn fire_column(out: &mut Vec<SmokeSegment>, fire_id: u32, at: DVec3, start: u64, size: f32, shown: bool, tick: u64, level: &dyn Fn(u64) -> (f32, f32)) {
// Only the strongest fires get a full column; the rest a thin puff now and then.
let step = if shown { 15 } else { 90 };
let first = start.max(tick.saturating_sub(520));
let mut k = first.div_ceil(step) * step;
while k <= tick && out.len() < MAX_TOTAL {
let (flame, smoke) = level(k);
if smoke > 0.05 && k >= start {
let id = 0x4000_0000 | (fire_id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
let at = at + DVec3::Y * 0.6;
let mut s = seg(id, at, at, k, 540);
s.radius_start = 1.0 * size;
s.radius_end = 8.0 * size + 2.0;
s.density = 2.6 * smoke * size.sqrt() * if shown { 1.0 } else { 0.5 };
s.heat = flame * 0.6;
s.soot = 0.85;
s.rise = 2.6;
out.push(s);
}
k += step;
}
}
/// Blasts: a billow of puffs going up, for 900 ticks after each.
fn blast_billows(out: &mut Vec<SmokeSegment>, blasts: &[crate::sim::Blast], tick: u64) {
for b in blasts {
let age = tick.saturating_sub(b.start);
if age > 900 {
continue;
}
for j in 0..6u64 {
let born = b.start + j * 3;
if born > tick {
break;
}
let h = b.size as f64 * (0.2 + 0.5 * j as f64);
let at = b.at + DVec3::Y * h;
let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800);
s.radius_start = b.size * 0.6;
s.radius_end = b.size * 3.2 + 4.0;
s.density = 2.4 / (1.0 + j as f32 * 0.25);
s.heat = if j < 3 { 1.0 } else { 0.4 };
s.soot = 0.55;
s.rise = 4.0 - 0.3 * j as f32;
out.push(s);
}
}
}
fn style_drone(s: &mut SmokeSegment, class: Class) {
let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class {
Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0),
Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0),
Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0),
Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0),
};
s.radius_start = r0;
s.radius_end = r1;
s.density = dens;
s.heat = heat;
s.soot = soot;
s.clear = clear;
s.life = life;
}
fn style_missile(s: &mut SmokeSegment) {
s.radius_start = 0.6;
s.radius_end = 5.0;
s.density = 3.0;
s.heat = 0.8;
s.soot = 0.05;
s.clear = 3.0;
s.life = 1000.0;
}
/// Points between a replay trail segment's ends (a segment every `TRAIL_GRID * STEP` ticks).
const TRAIL_GRID: usize = 4;
/// The smoke a recording shows at its tick `t`: trails behind everything that flew, columns over
/// the fires its events lit, billows over its blasts. A pure function of `t` (nothing is kept
/// between calls, so a seek, a pause or a cut shows exactly what playing through would), and it
/// touches no live state. Trail segments sit on a fixed grid of each track's points.
pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSegment> {
use crate::sim::record::{STEP, What};
let tick = t.max(0.0) as u64;
let mut out: Vec<SmokeSegment> = Vec::new();
let stride = (TRAIL_GRID as u32 * STEP) as f64;
for (n, tr) in rec.tracks.iter().enumerate() {
let (life, style): (f64, Box<dyn Fn(&mut SmokeSegment)>) = match tr.what {
What::Drone(kind) => {
let class = kind.spec().class;
let mut probe = seg(0, DVec3::ZERO, DVec3::ZERO, 0, 0);
style_drone(&mut probe, class);
(probe.life as f64, Box::new(move |s| style_drone(s, class)))
}
What::Missile(..) => (1000.0, Box::new(style_missile)),
};
// The segments whose end point has been reached and whose life is not over.
let lo = (((t - life - tr.first as f64) / stride).floor().max(0.0)) as usize;
let hi = (((t - tr.first as f64) / stride).floor().max(0.0)) as usize;
for g in lo.max(1)..=hi {
let (Some(a), Some(b)) = (tr.sample(rec.origin, (g - 1) * TRAIL_GRID), tr.sample(rec.origin, g * TRAIL_GRID)) else { break };
let born = tr.first as u64 + (g * TRAIL_GRID) as u64 * STEP as u64;
if born > tick || a.distance(b) >= 400.0 {
continue;
}
let mut s = seg(0x2000_0000 | ((n as u32).wrapping_mul(7919) ^ g as u32) & 0x0fff_ffff, a, b, born, 1200);
style(&mut s);
if (tick as f64) < born as f64 + s.life as f64 {
out.push(s);
}
}
}
if out.len() > MAX_TRAIL {
out.sort_by_key(|s| s.born as u64);
let cut = out.len() - MAX_TRAIL;
out.drain(..cut);
}
// Fires, the strongest dozen in full (as the live ones are).
let mut fires = crate::sim::record::replay_fires(rec, t);
fires.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.start.cmp(&b.start)));
for (rank, f) in fires.iter().enumerate() {
let life = f.life as f64;
fire_column(&mut out, 0x1000_0000 | f.key as u32, f.at, f.start as u64, f.size, rank < crate::sim::fires::MAX, tick, &|k| crate::sim::fires::level(k.saturating_sub(f.start as u64) as f64, life));
}
// Blasts keep their billows long after the fireball is out.
let blasts: Vec<crate::sim::Blast> = rec
.events
.iter()
.enumerate()
.filter(|(_, e)| e.at as u64 <= tick)
.map(|(k, e)| {
let (size, life) = crate::sim::record::blast_spec(e.kind);
crate::sim::Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life }
})
.collect();
blast_billows(&mut out, &blasts, tick);
out.truncate(MAX_TOTAL);
out
}
impl Smoke {
fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, style: impl Fn(&mut SmokeSegment)) {
let Some(&(from, when)) = self.last.get(&key) else {
@@ -76,32 +220,10 @@ impl Smoke {
self.last_tick = tick;
for d in &sim.drones {
let class = d.kind.spec().class;
let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class {
Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0),
Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0),
Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0),
Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0),
};
self.trail((0, d.id), d.pos, tick, |s| {
s.radius_start = r0;
s.radius_end = r1;
s.density = dens;
s.heat = heat;
s.soot = soot;
s.clear = clear;
s.life = life;
});
self.trail((0, d.id), d.pos, tick, |s| style_drone(s, class));
}
for m in &sim.missiles {
self.trail((1, m.id), m.pos, tick, |s| {
s.radius_start = 0.6;
s.radius_end = 5.0;
s.density = 3.0;
s.heat = 0.8;
s.soot = 0.05;
s.clear = 3.0;
s.life = 1000.0;
});
self.trail((1, m.id), m.pos, tick, style_missile);
}
// Forget what is gone.
if tick % 100 == 0 {
@@ -129,50 +251,9 @@ impl Smoke {
if f.quiet && f.size < 1.0 {
continue;
}
// Only the strongest fires get a full column; the rest a thin puff now and then.
let step = if f.shown { 15 } else { 90 };
let first = f.start.max(tick.saturating_sub(520));
let mut k = first.div_ceil(step) * step;
while k <= tick && out.len() < MAX_TOTAL {
let (flame, smoke) = f.level(k);
if smoke > 0.05 && k >= f.start {
let id = 0x4000_0000 | (f.id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
let at = f.at + DVec3::Y * 0.6;
let mut s = seg(id, at, at, k, 540);
s.radius_start = 1.0 * f.size;
s.radius_end = 8.0 * f.size + 2.0;
s.density = 2.6 * smoke * f.size.sqrt() * if f.shown { 1.0 } else { 0.5 };
s.heat = flame * 0.6;
s.soot = 0.85;
s.rise = 2.6;
out.push(s);
}
k += step;
}
}
// Blasts: a billow of puffs going up.
for b in &sim.blasts {
let age = tick.saturating_sub(b.start);
if age > 900 {
continue;
}
for j in 0..6u64 {
let born = b.start + j * 3;
if born > tick {
break;
}
let h = b.size as f64 * (0.2 + 0.5 * j as f64);
let at = b.at + DVec3::Y * h;
let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800);
s.radius_start = b.size * 0.6;
s.radius_end = b.size * 3.2 + 4.0;
s.density = 2.4 / (1.0 + j as f32 * 0.25);
s.heat = if j < 3 { 1.0 } else { 0.4 };
s.soot = 0.55;
s.rise = 4.0 - 0.3 * j as f32;
out.push(s);
}
fire_column(&mut out, f.id, f.at, f.start, f.size, f.shown, tick, &|k| f.level(k));
}
blast_billows(&mut out, &sim.blasts, tick);
out.truncate(MAX_TOTAL);
out
}
@@ -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/34] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/35] 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/34] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/35] 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/34] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/35] 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/34] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/35] 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/34] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/35] 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/34] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/35] 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/34] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/35] 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/34] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/35] 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/34] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/35] 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/34] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/35] 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/34] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/35] 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/34] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/35] 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/34] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/35] 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/34] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/35] 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/34] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/35] 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/34] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/35] 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/34] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/35] 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/34] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/35] 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/34] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/35] 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/34] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/35] 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/34] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/35] 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/34] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/35] 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/34] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/35] 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/34] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/35] 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/34] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/35] 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/34] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/35] 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/34] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/35] 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/34] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/35] 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/34] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/35] 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/34] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/35] 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/34] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/35] 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/34] Spot lights for mods (render.set-spot-lights, API level
Subject: [PATCH 32/35] 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/34] HD clouds and fog polish: cloud panorama, cirrus,
Subject: [PATCH 33/35] HD clouds and fog polish: cloud panorama, cirrus,
terrain-aware fog, far smoke cascade; replay world log and film clock;
material overlays by sprite name
@@ -1,7 +1,7 @@
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:01:09 +0300
Subject: [PATCH 34/34] HD: coloured sun through glass and leaves, rain ripples
Subject: [PATCH 34/35] HD: coloured sun through glass and leaves, rain ripples
on water, film motion blur and depth of field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -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/35] 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)
+33
View File
@@ -1193,6 +1193,36 @@ def ew_truck():
return p
def launch_ramp():
"""Рампа-катапульта для дронов: основание, наклонные рельсы (нос на север поднят), опоры и ложе."""
p = [el((1, 0, -6), (15, 2, 30), "dark_metal"), el((6, 2, 0), (10, 4, 26), "metal")]
p += both(el((3, 9, -14), (5, 11, 30), "metal", rot=("x", 22.5)))
p += both(el((3, 0, -1), (5, 12, 1), "dark_metal")) + both(el((3, 0, 17), (5, 6, 19), "dark_metal"))
p += [el((4, 10, 24), (12, 14, 30), "dark_metal"), el((1, 2, 28), (15, 6, 32), "ru_khaki")]
return p
def launcher_truck():
"""Пусковая машина ракет: шасси МАЗ и наклонный пакет труб, смотрящий вперёд (на север) вверх."""
p = maz_chassis("ru_khaki")
for x in (4, 10):
p.append(el((x, 15, -4), (x + 5, 20, 30), "tube", {"north": "tube_cap", "south": "tube_cap"}, rot=("x", 45)))
p += [el((3, 13, 22), (13, 17, 28), "dark_metal")]
return p
def barge():
"""Баржа под пусковой комплект: плоская палуба 3×3 блока, корпус, борта, кнехты и скошенный нос."""
p = [el((-16, 0, -16), (32, 3, 32), "dark_metal"),
el((-16, 3, -16), (32, 4, 32), "metal"),
el((-15, 3, -15), (31, 4.4, 31), "ru_khaki")]
p += both(el((-16, 4, -16), (-14, 6, 32), "dark_metal"))
p += [el((-16, 4, 30), (32, 6, 32), "dark_metal"), el((-16, 4, -16), (32, 6, -14), "dark_metal")]
for z in (-10, 8, 26):
p += both(el((-12, 4.4, z), (-9, 6.5, z + 3), "metal"))
return p
MODELS = {
"shahed": (shahed, "Шахед-136"),
"shahed_prop": (shahed_prop, "Винт"),
@@ -1251,6 +1281,9 @@ MODELS = {
"radar_truck": (radar_truck, "Шасси РЛС"),
"depot": (depot, "Склад боеприпасов"),
"tzm_truck": (tzm_truck, "Транспортно-заряжающая машина"),
"barge": (barge, "Баржа"),
"launch_ramp": (launch_ramp, "Рампа запуска дронов"),
"launcher_truck": (launcher_truck, "Пусковая машина ракет"),
"ew_truck": (ew_truck, "Станция РЭБ"),
"searchlight": (searchlight, "Прожектор"),
"searchlight_head": (searchlight_head, "Голова прожектора"),
+36 -6
View File
@@ -20,7 +20,11 @@
Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между
кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не
участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям:
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Сильное мерцание
по кадрам одного прогона от движения не отличить, поэтому «движение» само ограничено: доля непроверенных
пикселей (--max-animated), сумма «неустойчивых» (--max-flicker), пиксели, ставшие движущимися против
БАЗЫ (--max-new-animated: маска берётся из базы, не из проверяемого прогона), и доля проверяемых
пикселей (--min-static: ноль статичных — не «ноль ошибки», а провал). Для движущейся части
выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску.
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
@@ -337,19 +341,31 @@ def motion_range(rgbs):
def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level):
"""По размаху каждого пикселя между кадрами одного прогона. `pct` — мерцание: размах в
(flicker_level, motion_level]; `animated_pct` — выше motion_level (движение: вода, облака,
листва; ИЛИ очень сильное мерцание — по одному прогону их не различить). `unsteady_pct` — сумма
обоих: все пиксели с размахом выше flicker_level, она не падает, когда мерцание усиливается и
переходит из одной корзины в другую. `unverified_pct` — доля пикселей, которые этот прогон
ничем не проверяет (то же, что animated_pct); `static_pct` — доля проверяемых."""
npx = w * h
flick = sum(1 for r in rng if flicker_level < r <= motion_level)
moving = sum(1 for r in rng if r > motion_level)
return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx,
"unsteady_pct": 100.0 * (flick + moving) / npx,
"unverified_pct": 100.0 * moving / npx, "static_pct": 100.0 * (npx - moving) / npx,
"frames": n_frames, "size": [w, h]}
def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None):
"""Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух
прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная
средняя разница (информация, не порог)."""
"""Первый кадр текущего прогона против первого кадра базы. Из сравнения исключаются только
пиксели, которые двигались уже в БАЗЕ (маска воспроизводимая, не зависит от того, что показал
проверяемый прогон): mean и pct — по остальным; для движущихся — отдельная средняя разница
(информация, не порог). Пиксель, ставший «движущимся» только в текущем прогоне (мерцание,
призраки TAA), из сравнения не исключается и считается в new_animated_pct. Доля проверяемых
пикселей — static_pct: при нуле mean не «0 ошибки», а отсутствие проверки (см. judge)."""
npx = w * h
static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng))
static = bytes(1 if b <= motion_level else 0 for b in base_rng)
new_anim = sum(1 for a, b in zip(cur_rng, base_rng) if a > motion_level and b <= motion_level)
d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0])))
dpix = _per_pixel_max(d)
n_static = sum(static)
@@ -358,6 +374,7 @@ def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_
over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL)
moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving)
if n_moving else 0.0)
cur_anim = sum(1 for a in cur_rng if a > motion_level)
if diff_out:
img = bytearray(min(255, v * DIFF_GAIN) for v in d)
for i, s in enumerate(static):
@@ -368,7 +385,9 @@ def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_
if motion_out:
write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static))))
return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx,
"animated_mean": moving_mean, "size": [w, h]}
"animated_mean": moving_mean, "static_pct": 100.0 * n_static / npx,
"new_animated_pct": 100.0 * new_anim / npx, "cur_animated_pct": 100.0 * cur_anim / npx,
"size": [w, h]}
# --- тайминги -----------------------------------------------------------------------------------
@@ -482,11 +501,17 @@ def judge(entry, args):
fl = entry.get("flicker")
if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker:
reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
if fl and fl["animated_pct"] > args.max_animated:
reasons.append(f"не проверено (размах > {args.motion_level}/255) {fl['animated_pct']:.1f}% кадра > {args.max_animated:g}%")
base = entry.get("vs_baseline")
if base:
if base.get("error"):
reasons.append(base["error"])
else:
if base["static_pct"] < args.min_static:
reasons.append(f"проверяемых пикселей {base['static_pct']:.1f}% < {args.min_static:g}%: сравнение с базой ничего не доказывает")
if base["new_animated_pct"] > args.max_new_animated:
reasons.append(f"новых движущихся пикселей против базы {base['new_animated_pct']:.2f}% > {args.max_new_animated:g}%")
if base["mean"] > args.max_mean:
reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}")
if base["pct"] > args.max_pct:
@@ -522,6 +547,8 @@ def write_report(out, entries, args, env_extra, size, baseline, meta):
fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-"
mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-"
b = e.get("vs_baseline") or {}
if "new_animated_pct" in b: # what the baseline comparison covers and what grew
mv += f" (проверено {b['static_pct']:.0f}%, новых {b['new_animated_pct']:.2f}%)"
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
@@ -584,6 +611,9 @@ def parse_args(argv):
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255")
p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%")
p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)")
p.add_argument("--max-animated", type=float, default=60.0, help="порог доли кадра, которую прогон не проверяет (размах выше --motion-level), %%; не откалиброван")
p.add_argument("--max-new-animated", type=float, default=2.0, help="порог пикселей, статичных в базе и движущихся в прогоне, %%; не откалиброван")
p.add_argument("--min-static", type=float, default=10.0, help="наименьшая доля проверяемых (статичных в базе) пикселей, %%")
p.add_argument("--no-freeze", action="store_true",
help="не замораживать игровое время и погоду (живой замер производительности, не снимки)")
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
+89
View File
@@ -0,0 +1,89 @@
"""Tests of tools/hd_bench.py's comparison (#82): `python3 -m unittest tools/test_hd_bench.py`."""
import argparse
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import hd_bench as hb # noqa: E402
def frame(w, h, v):
return bytes([v]) * (3 * w * h)
def args(**kw):
a = argparse.Namespace(budget_ms=18.0, max_flicker=1.0, max_animated=60.0, max_mean=3.0,
max_pct=5.0, max_new_animated=2.0, min_static=10.0, motion_level=hb.MOTION_LEVEL)
a.__dict__.update(kw)
return a
def judged(rgbs, base_rgbs, w=2, h=2):
rng = hb.motion_range(rgbs)
brng = hb.motion_range(base_rgbs)
entry = {"gpu_avg_ms": 1.0}
entry["flicker"] = hb.flicker_stats(rng, w, h, len(rgbs), hb.MOTION_LEVEL, hb.FLICKER_LEVEL)
entry["vs_baseline"] = hb.compare_runs(rgbs, rng, base_rgbs, brng, w, h, hb.MOTION_LEVEL)
return entry, hb.judge(entry, args())
class FlickerIsNotAnimation(unittest.TestCase):
def test_the_issues_black_white_strobe_fails(self):
# The issue: a 2x2 frame, the baseline steadily black, the run alternating black and white.
entry, reasons = judged([frame(2, 2, 0), frame(2, 2, 255)] * 2, [frame(2, 2, 0)] * 3)
self.assertEqual(entry["flicker"]["pct"], 0.0) # the old blind spot is still in these two numbers ...
self.assertEqual(entry["flicker"]["animated_pct"], 100.0)
self.assertEqual(entry["flicker"]["unsteady_pct"], 100.0)
self.assertEqual(entry["vs_baseline"]["new_animated_pct"], 100.0)
self.assertTrue(reasons, "... but the verdict is a failure")
def test_stronger_flicker_never_starts_passing(self):
w = h = 4
base = [frame(w, h, 100)] * 3
failed = []
unsteady = []
for amp in (8, 16, 32, 255):
lo, hi = 100 - min(amp // 2, 100), min(255, 100 + (amp + 1) // 2)
run = [frame(w, h, lo), frame(w, h, hi), frame(w, h, lo)]
entry, reasons = judged(run, base, w, h)
failed.append(bool(reasons))
unsteady.append(entry["flicker"]["unsteady_pct"])
self.assertEqual(failed, [True] * 4, failed)
self.assertEqual(unsteady, sorted(unsteady))
def test_a_fully_dynamic_frame_is_not_a_zero_error(self):
w = h = 2
moving = [frame(w, h, 0), frame(w, h, 255)]
entry, reasons = judged(moving, moving)
self.assertEqual(entry["vs_baseline"]["static_pct"], 0.0)
self.assertTrue(any("проверяемых" in r for r in reasons), reasons)
def test_water_and_foliage_that_also_move_in_the_baseline_pass(self):
w, h = 10, 10
n = w * h
def mixed(a, b): # the left 20% animates (water), the rest is still
f = bytearray(frame(w, h, 90))
for px in range(n // 5):
f[3 * px:3 * px + 3] = bytes([a]) * 3
return bytes(f)
run = [mixed(40, 40), mixed(120, 40), mixed(60, 40)]
base = [mixed(30, 40), mixed(130, 40), mixed(70, 40)]
entry, reasons = judged(run, base, w, h)
self.assertEqual(entry["vs_baseline"]["new_animated_pct"], 0.0)
self.assertEqual(reasons, [])
def test_the_mask_comes_from_the_baseline_not_from_the_run(self):
# Growth of the animated area against the baseline is counted and bounded.
w = h = 10
base = [frame(w, h, 90)] * 3
f = bytearray(frame(w, h, 90))
f[0:30] = bytes([250]) * 30 # 10% of the pixels
run = [bytes(f), frame(w, h, 90), bytes(f)]
entry, reasons = judged(run, base, w, h)
self.assertAlmostEqual(entry["vs_baseline"]["new_animated_pct"], 10.0)
self.assertTrue(reasons)
if __name__ == "__main__":
unittest.main()