Compare commits

..

1 Commits

Author SHA1 Message Date
chexit 9bc94fa8aa Raids launch from real sites: 1, 2 or 4-5 sides, stock and launch queue, land first, then nearer land, then barges on water (#100)
build / mod (push) Successful in 3m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:36:43 +03:00
14 changed files with 5403 additions and 16 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

+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());
}
+3 -1
View File
@@ -1348,11 +1348,13 @@ fn raids_have_the_same_strike_accuracy_as_manual_launches() {
sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0));
let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects };
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default());
// (The raid's launch sites need land the game has told of: a flat one is served.)
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
for _ in 0..40 {
if !sim.planning() {
break;
}
sim.step(&mut Ground::default(), None, &mut Out::default());
sites::testland::step(&mut sim, &mut ground, &land, &mut Out::default());
}
assert!(!sim.queue.is_empty() && sim.queue.iter().all(|(_, d)| d.err != DVec3::ZERO), "raid drones without a navigation error");
let mut d = sim.make_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, -500.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
+832
View File
@@ -0,0 +1,832 @@
//! Launch sites (#100): where a raid really starts from. A raid picks its sides (one, two, or four to five),
//! looks for suitable ground on each side (only land the game has told of: flat, open to the sky, clear in
//! front, away from the defence), and sets up a site there: a ramp for the drones, a launcher vehicle for the
//! cruise missiles, a battery for the ballistic ones (at their own range). The raid's drones are jobs of the
//! sites: each one leaves its site, one at a time, from the stock the site holds. Ground nobody has told of is
//! never suitable, and nothing is force-loaded: a side without suitable ground waits for the game's answers
//! for a few seconds, and is then given up with a message.
use super::*;
/// The most candidate places tried for one site, and how long (ticks) a raid waits for the game's answers.
pub const MAX_TRIES: usize = 90;
pub const SEARCH_TICKS: u32 = 300;
/// Candidates asked about ahead of the one being waited for.
const PREFETCH: usize = 3;
/// The footprint's samples (offsets in blocks), the most the ground may differ over it, and how far from
/// the defence and the objects a site must be.
const FOOTPRINT: [(f64, f64); 9] = [(0.0, 0.0), (8.0, 0.0), (-8.0, 0.0), (0.0, 8.0), (0.0, -8.0), (6.0, 6.0), (6.0, -6.0), (-6.0, 6.0), (-6.0, -6.0)];
const FLAT: i32 = 2;
const KEEP_OUT: f64 = 45.0;
/// What a site is for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SiteClass {
/// Drones: a catapult ramp.
Ramp,
/// Cruise and supersonic missiles: a launcher vehicle.
Launcher,
/// Ballistic missiles: a heavier launcher, far out.
Battery,
}
impl SiteClass {
pub fn of(kind: DroneKind) -> SiteClass {
let spec = kind.spec();
if spec.ballistic.is_some() {
SiteClass::Battery
} else if matches!(spec.class, Class::Drone | Class::Jet) {
SiteClass::Ramp
} else {
SiteClass::Launcher
}
}
/// Whether a site of this class can launch this kind.
pub fn supports(self, kind: DroneKind) -> bool {
SiteClass::of(kind) == self
}
/// Ticks between two launches from one site.
pub fn cadence(self) -> u64 {
match self {
SiteClass::Ramp => 3,
SiteClass::Launcher => 8,
SiteClass::Battery => 20,
}
}
fn slots(self) -> u32 {
match self {
SiteClass::Ramp => 4,
SiteClass::Launcher => 3,
SiteClass::Battery => 2,
}
}
fn spacing(self) -> f64 {
match self {
SiteClass::Ramp => 4.0,
SiteClass::Launcher => 9.0,
SiteClass::Battery => 12.0,
}
}
fn model(self) -> &'static str {
match self {
SiteClass::Ramp => "airdef:launch_ramp",
SiteClass::Launcher | SiteClass::Battery => "airdef:launcher_truck",
}
}
fn scale(self) -> f64 {
match self {
SiteClass::Ramp => 1.4,
SiteClass::Launcher => 1.5,
SiteClass::Battery => 1.9,
}
}
/// The barge's scale: its deck is 3 blocks a side at 1.
fn barge(self) -> f64 {
match self {
SiteClass::Ramp => 4.5,
_ => 7.0,
}
}
/// Where a drone leaves the kit: blocks ahead of its centre, and up from the ground.
fn muzzle(self) -> (f64, f64) {
match self {
SiteClass::Ramp => (1.8, 1.7),
SiteClass::Launcher => (2.6, 3.4),
SiteClass::Battery => (3.2, 4.2),
}
}
/// How steeply a drone leaves it (radians).
pub fn pitch(self) -> f64 {
match self {
SiteClass::Ramp => 0.35,
SiteClass::Launcher => 0.6,
SiteClass::Battery => 0.9,
}
}
}
/// One launch site of a raid: stable id, side, where, what it can launch, what is left, and when it may launch next.
#[derive(Clone, Debug)]
pub struct LaunchSite {
pub id: u32,
/// Which side of the raid it is on (0-4) and the enemy's sector of it.
pub side: u8,
pub sector: u8,
pub bearing: f64,
pub class: SiteClass,
/// The ground at its centre (the top of the surface), and how far from the raid's centre it is.
pub pos: DVec3,
pub range: f64,
/// On a barge over open water (its `pos` is the water's surface plus the deck).
pub water: bool,
/// Rounds left, and the jobs given to it (also how many pads are drawn).
pub stock: u32,
pub planned: u32,
pub alive: bool,
pub next_free: u64,
}
impl LaunchSite {
/// The direction a launch goes: from the site in, towards the raid's centre.
pub fn inward(&self) -> DVec3 {
DVec3::new(-self.bearing.sin(), 0.0, -self.bearing.cos())
}
fn lateral(&self) -> DVec3 {
DVec3::new(self.bearing.cos(), 0.0, -self.bearing.sin())
}
/// The base of pad `i` on the ground.
pub fn pad(&self, i: u32) -> DVec3 {
let n = self.class.slots();
self.pos + self.lateral() * ((f64::from(i % n) - f64::from(n - 1) / 2.0) * self.class.spacing())
}
/// Where a drone of pad `i` leaves the kit.
pub fn muzzle(&self, i: u32) -> DVec3 {
let (ahead, up) = self.class.muzzle();
self.pad(i) + self.inward() * ahead + DVec3::Y * up
}
pub fn supports(&self, kind: DroneKind) -> bool {
self.class.supports(kind)
}
}
/// The sites in play and the launch jobs given to them: (drone id, site id) for each drone still in the queue.
#[derive(Default)]
pub struct Sites {
pub list: Vec<LaunchSite>,
next: u32,
jobs: Vec<(u32, u32)>,
}
/// What a site says about a drone due to leave.
pub(super) enum Slot {
/// Not a site's job (a manual launch, an old save): off it goes.
Free,
Go,
/// The site launched a moment ago.
Busy,
/// The site is gone or empty: the job is cancelled.
Gone,
}
impl Sites {
pub fn get(&self, id: u32) -> Option<&LaunchSite> {
self.list.iter().find(|s| s.id == id)
}
/// Jobs waiting for a site.
pub fn pending(&self, id: u32) -> usize {
self.jobs.iter().filter(|j| j.1 == id).count()
}
/// The site a queued drone is a job of.
pub fn site_of(&self, drone: u32) -> Option<u32> {
self.jobs.iter().find(|j| j.0 == drone).map(|j| j.1)
}
pub fn clear(&mut self) {
self.list.clear();
self.jobs.clear();
}
pub(super) fn add(&mut self, mut site: LaunchSite) -> u32 {
self.next += 1;
site.id = self.next;
let id = site.id;
self.list.push(site);
id
}
pub(super) fn job(&mut self, drone: u32, site: u32) {
self.jobs.push((drone, site));
}
/// The old raid's sites go, but not those with jobs still to fly.
pub(super) fn retire(&mut self, queued: &[u32]) {
self.jobs.retain(|j| queued.contains(&j.0));
let jobs = &self.jobs;
self.list.retain(|s| jobs.iter().any(|j| j.1 == s.id));
}
fn take(&mut self, drone: u32, now: u64) -> Slot {
let Some(j) = self.jobs.iter().position(|j| j.0 == drone) else { return Slot::Free };
let site = self.jobs[j].1;
let Some(s) = self.list.iter_mut().find(|s| s.id == site) else {
self.jobs.swap_remove(j);
return Slot::Gone;
};
if !s.alive || s.stock == 0 {
self.jobs.swap_remove(j);
return Slot::Gone;
}
if s.next_free > now {
return Slot::Busy;
}
s.stock -= 1;
s.next_free = now + s.class.cadence();
self.jobs.swap_remove(j);
Slot::Go
}
}
// ---------------------------------------------------------------- choosing sides and places
/// The sides of a raid as bearings: one, two far apart (108° or more), or four to five spread round (at least
/// 43° apart). The seed decides which.
pub fn pick_sides(rng: &mut Rng, approach: Approach, bearing: f64) -> Vec<f64> {
match approach {
Approach::Sector | Approach::Waves => vec![bearing],
Approach::TwoSectors => vec![bearing, bearing + std::f64::consts::PI * (0.6 + rng.f() * 0.6)],
Approach::AllSides => {
let n = if rng.chance(0.5) { 4 } else { 5 };
let step = TAU / f64::from(n);
(0..n).map(|k| bearing + f64::from(k) * step + rng.s() * 0.2 * step).collect()
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Search {
Open,
Found,
Failed,
}
/// A site wanted by a raid: its side and class, the places to try in order, and how the search stands.
pub(super) struct Want {
pub side: usize,
pub bearing: f64,
pub class: SiteClass,
pub range: f64,
/// The range ordered (`range` is where the site stood in the end).
pub ordered: f64,
cands: Vec<(DVec3, bool)>,
cursor: usize,
pub found: Option<DVec3>,
pub water: bool,
pub state: Search,
pub jobs: u32,
pub site: u32,
}
/// The want of this side, class and range, made if there is none yet.
pub(super) fn want_index(wants: &mut Vec<Want>, centre: DVec3, side: usize, bearing: f64, class: SiteClass, range: f64, lo: f64) -> usize {
if let Some(i) = wants.iter().position(|w| w.side == side && w.class == class && (w.range - range).abs() < 1.0) {
return i;
}
let out = DVec3::new(bearing.sin(), 0.0, bearing.cos());
let lat = DVec3::new(bearing.cos(), 0.0, -bearing.sin());
// In order: known land at the ordered range (round it for most, the ordered range for a ballistic battery),
// known land nearer in steps, then a barge on open water at the ordered range, then on water nearer.
let near = |from: f64| -> Vec<f64> {
let min = if class == SiteClass::Battery { lo.min(from).max(80.0) } else { (from * 0.3).max(80.0) };
(1..=6).map(|i| from - (from - min) * f64::from(i) / 6.0).collect()
};
let round: Vec<f64> = if class == SiteClass::Battery {
vec![range]
} else {
[0.0, 24.0, -24.0, 48.0, -48.0, 72.0, -72.0].iter().map(|d| (range + d).max(KEEP_OUT)).collect()
};
let laterals: &[f64] = &[0.0, 20.0, -20.0];
let mut cands = Vec::new();
for water in [false, true] {
for r in round.iter().copied().chain(near(range)) {
for l in laterals {
cands.push((centre + out * r + lat * *l, water));
}
}
}
cands.truncate(MAX_TRIES);
wants.push(Want { side, bearing, class, range, ordered: range, cands, cursor: 0, found: None, water: false, state: Search::Open, jobs: 0, site: 0 });
wants.len() - 1
}
enum Verdict {
/// The game hasn't told enough yet.
Pending,
Bad,
/// Suitable: the surface at its centre, and whether it is water's.
Good(DVec3, bool),
}
/// Whether a site fits at `p` (x, z): every sample of its footprint is land the game told of and within
/// `FLAT` blocks of the others, the sky above is open, the way ahead is clear, and nothing of the defence is near.
fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64) -> Verdict {
if p.x.abs() > 29_000_000.0 || p.z.abs() > 29_000_000.0 || keep.iter().any(|k| horizontal(*k - p).length() < KEEP_OUT) {
return Verdict::Bad;
}
let (mut lo, mut hi, mut pending) = (i32::MAX, i32::MIN, false);
for (dx, dz) in FOOTPRINT {
match ground.column((p.x + dx).floor() as i32, (p.z + dz).floor() as i32, 256) {
None => pending = true,
// No land to tell of (the sea, the void): never a site.
Some(None) => return Verdict::Bad,
Some(Some(h)) => {
lo = lo.min(h as i32);
hi = hi.max(h as i32);
}
}
}
if pending {
return Verdict::Pending;
}
if hi - lo > FLAT {
return Verdict::Bad;
}
let top = f64::from(hi);
let at = DVec3::new(p.x.floor() + 0.5, top, p.z.floor() + 0.5);
// The game's height is the top of what blocks motion *or holds a fluid*: the block under the surface is
// solid at the footprint's centre and edges (land), or at none of them (water); a shore is neither.
let mut solid = 0;
for (dx, dz) in FOOTPRINT.iter().take(5) {
let c = DVec3::new((p.x + dx).floor() + 0.5, top, (p.z + dz).floor() + 0.5);
match ground.world.sweep(c + DVec3::Y * 0.5, c - DVec3::Y * 0.5, false, now) {
world::Sweep::Hit { .. } => solid += 1,
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { .. } => return Verdict::Bad,
}
}
if solid != 0 && solid != 5 {
return Verdict::Bad;
}
let up = ground.world.sweep(at + DVec3::Y * 30.0, at + DVec3::Y * 1.0, false, now);
let ahead = ground.world.sweep(at + DVec3::Y * 3.0, at + inward * 14.0 + DVec3::Y * 3.0, false, now);
for s in [up, ahead] {
match s {
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { .. } | world::Sweep::Hit { .. } => return Verdict::Bad,
}
}
Verdict::Good(at, solid == 0)
}
/// Works on the open wants: each tries its places in order, a place the game hasn't told enough of holds the
/// search (the next few are asked about meanwhile), and a bad place is passed over. After `last` nothing waits.
/// Returns whether every want is settled.
pub(super) fn search(wants: &mut [Want], ground: &mut Ground, centre: DVec3, keep: &[DVec3], now: u64, last: bool) -> bool {
let mut all = true;
for k in 0..wants.len() {
if wants[k].state != Search::Open {
continue;
}
// Two sites never share a place: the others' kit is in the way.
let mut keep: Vec<DVec3> = keep.to_vec();
keep.extend(wants.iter().filter_map(|o| o.found));
let w = &mut wants[k];
let inward = DVec3::new(-w.bearing.sin(), 0.0, -w.bearing.cos());
loop {
if w.cursor >= w.cands.len() {
w.state = Search::Failed;
break;
}
let (cand, water) = w.cands[w.cursor];
match judge(ground, cand, inward, &keep, now) {
Verdict::Bad => w.cursor += 1,
// Land when land was asked for, a barge when water was.
Verdict::Good(_, got) if got != water => w.cursor += 1,
Verdict::Good(at, _) => {
w.found = Some(at);
w.water = water;
w.range = horizontal(at - centre).length();
w.state = Search::Found;
break;
}
Verdict::Pending => {
for k in 1..=PREFETCH {
if let Some(&(c, _)) = w.cands.get(w.cursor + k) {
let _ = judge(ground, c, inward, &keep, now);
}
}
if last {
w.state = Search::Failed;
} else {
all = false;
}
break;
}
}
}
}
all
}
// ---------------------------------------------------------------- in the sim
/// Id base of the kit's models.
const KIT: u32 = 0x7400_0000;
/// The barge's deck over the water.
pub const DECK: f64 = 0.3;
impl Sim {
/// Takes site `id` out of play (destroyed): its stock is gone and its queued jobs are cancelled. Returns how
/// many. (The counter-strike, #101, uses this.)
pub fn destroy_site(&mut self, id: u32) -> u32 {
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id) else { return 0 };
s.alive = false;
s.stock = 0;
let ids: Vec<u32> = self.sites.jobs.iter().filter(|j| j.1 == id).map(|j| j.0).collect();
self.queue.retain(|q| !ids.contains(&q.1.id));
self.sites.jobs.retain(|j| j.1 != id);
ids.len() as u32
}
/// Asks a site whether a queued drone may leave now.
pub(super) fn site_slot(&mut self, drone: u32) -> Slot {
let now = self.tick;
self.sites.take(drone, now)
}
/// Draws every site's kit: a ramp or launcher per pad in use, turned to the way the launches go.
pub(super) fn draw_sites(&self, out: &mut Out) {
for s in self.sites.list.iter().filter(|s| s.alive) {
let rot = DQuat::from_rotation_y(heading_of(s.inward()));
let sc = s.class.scale();
if s.water {
let b = s.class.barge();
out.model(KIT + (s.id * 8 + 7) * PARTS, "airdef:barge", s.pos - DVec3::Y * (DECK + 0.8 - 0.5 * b), rot, b, false);
}
for i in 0..s.planned.min(s.class.slots()) {
let id = KIT + (s.id * 8 + i) * PARTS;
out.model(id, s.class.model(), s.pad(i) + DVec3::Y * 0.5 * sc, rot, sc, false);
}
}
}
}
#[cfg(test)]
pub(super) mod testland {
use super::*;
/// Land from a height function; blocks below it are solid, and the game's height map says the same.
pub struct Land<F: Fn(i32, i32) -> i32>(pub F);
impl<F: Fn(i32, i32) -> i32> world::WorldSource for Land<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < (self.0)(x, z))
}
}
/// What the game answers a tick after the sim asked: heights, and the sections.
pub fn serve<F: Fn(i32, i32) -> i32>(ground: &mut Ground, land: &Land<F>, now: u64) {
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (land.0)(x, z))).collect();
ground.answer(columns, now);
let sections = ground.world.take_wanted(now);
ground.world.serve(land, sections, now);
}
pub fn step<F: Fn(i32, i32) -> i32>(sim: &mut Sim, ground: &mut Ground, land: &Land<F>, out: &mut Out) {
serve(ground, land, sim.tick);
sim.step(ground, None, out);
}
}
#[cfg(test)]
mod tests {
use super::testland::*;
use super::*;
fn sep(a: f64, b: f64) -> f64 {
angle_diff(a, b).abs().to_degrees()
}
fn min_sep(sides: &[f64]) -> f64 {
let mut m = 360.0f64;
for i in 0..sides.len() {
for j in 0..i {
m = m.min(sep(sides[i], sides[j]));
}
}
m
}
#[test]
fn sector_modes_give_one_two_or_four_to_five_sides_apart() {
let mut fours = 0;
let mut fives = 0;
for seed in 1..200u64 {
for approach in Approach::ALL {
let mut rng = Rng::new(seed);
let b = rng.f() * TAU;
let sides = pick_sides(&mut rng, approach, b);
match approach {
Approach::Sector | Approach::Waves => assert_eq!(sides.len(), 1),
Approach::TwoSectors => {
assert_eq!(sides.len(), 2);
assert!(min_sep(&sides) >= 100.0, "two sectors only {:.0} deg apart", min_sep(&sides));
}
Approach::AllSides => {
assert!((4..=5).contains(&sides.len()), "{} sides", sides.len());
assert!(min_sep(&sides) >= 40.0, "all sides: two only {:.0} deg apart", min_sep(&sides));
if sides.len() == 4 {
fours += 1;
} else {
fives += 1;
}
}
}
}
}
assert!(fours > 20 && fives > 20, "the seed should pick both 4 and 5: {fours} / {fives}");
}
fn flat() -> Land<impl Fn(i32, i32) -> i32> {
Land(|_, _| 64)
}
fn order(approach: Approach, dist: f64) -> RaidOrder {
RaidOrder {
kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Gerbera, 3), (DroneKind::Kh101, 3), (DroneKind::Iskander, 2)],
approach,
dist,
aim: Aim::Objects,
}
}
/// Orders a raid over `land` and plans it to the end; returns the ticks it took.
fn plan<F: Fn(i32, i32) -> i32>(sim: &mut Sim, ground: &mut Ground, land: &Land<F>, o: &RaidOrder, out: &mut Out) -> u32 {
sim.order_raid(o, &[DVec3::new(0.0, 70.0, 0.0)], out);
let mut n = 0;
while sim.planning() {
step(sim, ground, land, out);
n += 1;
assert!(n < 1000, "the raid never finished planning");
}
n
}
#[test]
fn sites_per_sector_mode_launch_every_drone_from_a_site_on_a_side() {
for (approach, sides) in [(Approach::Sector, 1usize), (Approach::TwoSectors, 2), (Approach::AllSides, 5), (Approach::Waves, 1)] {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(21);
plan(&mut sim, &mut ground, &land, &order(approach, 300.0), &mut out);
let mut sides_used: Vec<u8> = sim.sites.list.iter().map(|s| s.side).collect();
sides_used.sort_unstable();
sides_used.dedup();
match approach {
Approach::AllSides => assert!((4..=5).contains(&sides_used.len()), "{} sides", sides_used.len()),
_ => assert_eq!(sides_used.len(), sides, "{approach:?}"),
}
// No hidden extra position: at most a site per class on a side (ramp, launcher, one battery per range).
assert!(sim.sites.list.len() <= sides_used.len() * 3, "{} sites", sim.sites.list.len());
// Every drone has a site that supports its kind.
assert_eq!(sim.queue.len() + sim.drones.len(), 14, "{:?}", out.messages);
for (_, d) in &sim.queue {
let site = sim.sites.site_of(d.id).and_then(|id| sim.sites.get(id)).expect("a drone without a source site");
assert!(site.supports(d.kind), "{:?} from a {:?}", d.kind, site.class);
// It waits on the site's pad, over its ground.
assert!(horizontal(d.pos - site.pos).length() < 25.0, "{:?} is {:.0} away from its site", d.kind, horizontal(d.pos - site.pos).length());
assert!(d.pos.y > site.pos.y && d.pos.y < site.pos.y + 6.0);
}
for (i, a) in sim.sites.list.iter().enumerate() {
for b in &sim.sites.list[..i] {
assert!(horizontal(a.pos - b.pos).length() >= KEEP_OUT, "two sites on one place");
}
}
// Stock and jobs agree; ground is real: flat at 64.
for s in &sim.sites.list {
assert_eq!(s.stock as usize, sim.sites.pending(s.id));
assert_eq!(s.pos.y, 64.0);
}
}
}
#[test]
fn ballistic_sites_keep_their_own_range_and_ramps_the_ordered_one() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(4);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
let centre = DVec3::new(0.0, 70.0, 0.0);
for s in &sim.sites.list {
let r = horizontal(s.pos - centre).length();
match s.class {
SiteClass::Battery => assert!(r > 990.0, "a battery only {r:.0} out"),
_ => assert!((200.0..=400.0).contains(&r), "{:?} {r:.0} out", s.class),
}
}
}
#[test]
fn launches_start_at_the_site_with_a_real_climb_and_in_turn() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(9);
plan(&mut sim, &mut ground, &land, &order(Approach::TwoSectors, 300.0), &mut out);
let jobs: HashMap<u32, u32> = sim.queue.iter().map(|(_, d)| (d.id, sim.sites.site_of(d.id).unwrap())).collect();
// One or two may have left in the tick the plan was ready.
let early = sim.drones.len();
let stock: u32 = sim.sites.list.iter().map(|s| s.stock).sum();
assert_eq!(stock as usize + early, 14);
let mut seen = HashMap::new();
let mut last_launch: HashMap<u32, u64> = HashMap::new();
for _ in 0..4000 {
step(&mut sim, &mut ground, &land, &mut out);
for d in sim.drones.iter().filter(|d| d.age == 1) {
let site = sim.sites.get(jobs[&d.id]).unwrap().clone();
// Its first tick is at the kit, not in the air somewhere.
assert!(horizontal(d.pos - site.pos).length() < 25.0, "{:?} first seen {:.0} from its site", d.kind, horizontal(d.pos - site.pos).length());
assert!(d.pos.y - site.pos.y < 12.0, "{:?} first seen {:.1} above the ground", d.kind, d.pos.y - site.pos.y);
if d.kind.spec().ballistic.is_none() {
assert!(d.vel.y > 0.0, "{:?} leaves level, not climbing", d.kind);
}
// One launch at a time from a site.
if let Some(t) = last_launch.insert(site.id, sim.tick) {
assert!(sim.tick - t >= site.class.cadence(), "two launches {} ticks apart", sim.tick - t);
}
seen.insert(d.id, d.pos.y);
}
}
assert_eq!(seen.len() + early, 14, "not every drone left a site");
assert_eq!(sim.sites.list.iter().map(|s| s.stock).sum::<u32>(), 0, "stock left after the raid");
// The climb is real: an early drone is higher a second later, and clear of the ground.
assert!(sim.queue.is_empty());
}
#[test]
fn a_site_launches_only_what_it_holds() {
let mut sim = Sim::new(2);
let mut ground = Ground::default();
let mut out = Out::default();
let id = sim.sites.add(LaunchSite {
id: 0,
side: 0,
sector: 0,
bearing: 0.0,
class: SiteClass::Ramp,
pos: DVec3::new(0.0, 64.0, 300.0),
range: 300.0,
water: false,
stock: 3,
planned: 5,
alive: true,
next_free: 0,
});
for i in 0..5 {
let site = sim.sites.get(id).unwrap().clone();
let d = sim.make_drone(DroneKind::Shahed, site.muzzle(i), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
sim.sites.job(d.id, id);
sim.queue.push((1, d));
}
let mut times = Vec::new();
for _ in 0..80 {
let before = sim.launched;
sim.step(&mut ground, None, &mut out);
if sim.launched > before {
times.push(sim.tick);
}
}
assert_eq!(sim.launched, 3, "launched {:?}", times);
assert!(sim.queue.is_empty(), "the unlaunchable jobs stay in the queue");
assert_eq!(sim.sites.get(id).unwrap().stock, 0);
assert!(times.windows(2).all(|w| w[1] - w[0] >= SiteClass::Ramp.cadence()), "{times:?}");
}
#[test]
fn a_destroyed_site_cancels_its_future_jobs() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(5);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
let ramp = sim.sites.list.iter().find(|s| s.class == SiteClass::Ramp).unwrap().id;
let early = sim.launched as usize;
let n = sim.sites.pending(ramp);
assert!(n >= 8 && n <= 9, "{n}");
assert_eq!(sim.destroy_site(ramp) as usize, n);
assert_eq!(sim.queue.len() + early + n, 14);
for _ in 0..3000 {
step(&mut sim, &mut ground, &land, &mut out);
}
assert_eq!(sim.launched as usize + n, 14);
}
#[test]
fn no_suitable_ground_means_a_clear_refusal() {
// Never answered: unknown ground is not suitable, and nothing is launched.
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(3);
sim.order_raid(&order(Approach::TwoSectors, 300.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
let mut n = 0;
while sim.planning() {
sim.step(&mut ground, None, &mut out);
n += 1;
assert!(n <= SEARCH_TICKS + 5, "a raid waits for ever");
}
assert!(sim.queue.is_empty() && sim.sites.list.is_empty());
assert!(out.messages.iter().any(|m| m.contains("площад")), "{:?}", out.messages);
assert!(!sim.drones.iter().any(|d| d.alive()));
// A cliff face everywhere (too steep to stand on) is refused as well, and a sea (no land) too.
for steep in [true, false] {
let land = Land(move |x, _| if steep { x * 4 } else { -100 });
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(3);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
assert!(sim.queue.is_empty() && sim.sites.list.is_empty(), "steep {steep}");
assert!(out.messages.iter().any(|m| m.contains("площад")));
}
}
/// Land inside a radius of the origin (surface 64), open water outside it (surface 63 over a floor at 50):
/// the game's height over water is the surface, and the blocks under it are not solid.
struct Island(f64);
impl Island {
fn land(&self, x: i32, z: i32) -> bool {
f64::from(x).hypot(f64::from(z)) < self.0
}
}
impl world::WorldSource for Island {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < if self.land(x, z) { 64 } else { 50 })
}
}
fn run_island(radius: f64, seed: u64, o: &RaidOrder) -> (Sim, Out) {
let (mut ground, mut out, island) = (Ground::default(), Out::default(), Island(radius));
let mut sim = Sim::new(seed);
sim.order_raid(o, &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
for _ in 0..SEARCH_TICKS + 5 {
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, if island.land(x, z) { 64 } else { 63 })).collect();
ground.answer(cols, sim.tick);
let secs = ground.world.take_wanted(sim.tick);
ground.world.serve(&island, secs, sim.tick);
sim.step(&mut ground, None, &mut out);
}
for _ in 0..3000 {
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, if island.land(x, z) { 64 } else { 63 })).collect();
ground.answer(cols, sim.tick);
let secs = ground.world.take_wanted(sim.tick);
ground.world.serve(&island, secs, sim.tick);
sim.step(&mut ground, None, &mut out);
}
(sim, out)
}
#[test]
fn an_all_water_map_gives_barges_and_every_drone_launches() {
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Kh101, 3)], approach: Approach::TwoSectors, dist: 200.0, aim: Aim::Objects };
// Water from the first block: nothing but the sea round the raid (its centre is the player's slab, not read).
let (sim, out) = run_island(0.0, 6, &o);
assert!(!sim.sites.list.is_empty(), "no barge: {:?}", out.messages);
assert!(sim.sites.list.iter().all(|s| s.water && s.pos.y > 63.0 && s.pos.y < 64.0), "a site that is not on the surface");
assert_eq!(sim.launched, 9, "{:?}", out.messages);
let mut o2 = Out::default();
sim.draw_sites(&mut o2);
assert!(o2.models.iter().any(|m| m.name == "airdef:barge"));
}
#[test]
fn land_is_preferred_over_water_when_both_are_known() {
// An island of radius 260 in the sea; the raid is ordered 300 out: its sites stand on the island, nearer.
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Kh101, 3)], approach: Approach::TwoSectors, dist: 300.0, aim: Aim::Objects };
let (sim, out) = run_island(260.0, 6, &o);
assert!(!sim.sites.list.is_empty());
assert!(sim.sites.list.iter().all(|s| !s.water), "a barge where land was known: {:?} {:?}", out.messages, sim.sites.list.iter().map(|s| (s.class, s.water, s.pos, s.range)).collect::<Vec<_>>());
assert_eq!(sim.launched, 9);
}
#[test]
fn a_site_never_stands_in_a_hill_or_under_a_roof() {
// A hill 40 blocks high round the raid's ring at 300 out (radius 100): the sites find flat land elsewhere.
let hill = |x: i32, z: i32| {
let (dx, dz) = (f64::from(x) - 0.0, f64::from(z) - 300.0);
if dx * dx + dz * dz < 100.0 * 100.0 { 64 + 40 } else { 64 }
};
let land = Land(hill);
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(8);
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 5)], approach: Approach::Sector, dist: 300.0, aim: Aim::Objects };
plan(&mut sim, &mut ground, &land, &o, &mut out);
for s in &sim.sites.list {
for (dx, dz) in FOOTPRINT {
let h = hill((s.pos.x + dx).floor() as i32, (s.pos.z + dz).floor() as i32);
assert!((f64::from(h) - s.pos.y).abs() <= f64::from(FLAT), "a site over a slope");
}
}
assert!(!sim.sites.list.is_empty());
}
#[test]
fn repeated_raids_do_not_pile_up_sites_and_clear_removes_them() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(12);
plan(&mut sim, &mut ground, &land, &order(Approach::AllSides, 300.0), &mut out);
let first: Vec<u32> = sim.sites.list.iter().map(|s| s.id).collect();
for _ in 0..3000 {
step(&mut sim, &mut ground, &land, &mut out);
}
plan(&mut sim, &mut ground, &land, &order(Approach::TwoSectors, 300.0), &mut out);
assert!(sim.sites.list.iter().all(|s| !first.contains(&s.id)), "old sites stayed");
assert!(sim.sites.list.len() <= 6);
sim.clear();
assert!(sim.sites.list.is_empty() && sim.queue.is_empty());
let mut o = Out::default();
sim.draw_sites(&mut o);
assert!(o.models.is_empty());
}
#[test]
fn the_same_seed_picks_the_same_sites_and_graphics_do_not_move_them() {
let run = |draw: bool| {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(33);
plan(&mut sim, &mut ground, &land, &order(Approach::AllSides, 300.0), &mut out);
if draw {
// Rendering must not spend the combat randomness.
let mut o = Out::default();
sim.draw_sites(&mut o);
assert!(!o.models.is_empty());
}
(sim.sites.list.iter().map(|s| (s.id, s.pos.x.to_bits(), s.pos.z.to_bits(), s.stock)).collect::<Vec<_>>(), sim.queue.iter().map(|q| q.0).collect::<Vec<_>>(), sim.rng.state())
};
assert_eq!(run(false), run(true));
}
}
+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, "Голова прожектора"),