diff --git a/airdef-mod/src/sim/orders.rs b/airdef-mod/src/sim/orders.rs index a368989..f236f29 100644 --- a/airdef-mod/src/sim/orders.rs +++ b/airdef-mod/src/sim/orders.rs @@ -48,7 +48,7 @@ impl Sim { return false; } sam.dest = Some(to); - let route = route::Route::new(sam.pos, to, slack, sam.kind.climbs(), self.tick); + let route = route::Route::new(sam.pos, to, slack, sam.kind, self.tick); self.routes.map.insert(sam.id, route); sam.target = None; sam.gun_target = None; @@ -102,7 +102,7 @@ impl Sim { sam.march = sam.kind.march_ticks(); continue; }; - let route = routes.map.entry(sam.id).or_insert_with(|| route::Route::new(sam.pos, to, 0.0, sam.kind.climbs(), now)); + let route = routes.map.entry(sam.id).or_insert_with(|| route::Route::new(sam.pos, to, 0.0, sam.kind, now)); let (go, last) = match route.drive(ground, now, sam.pos, share) { route::Drive::Wait(why) => { if route.waited(now) > 40 && !route.told { @@ -145,7 +145,8 @@ impl Sim { let turning = angle_diff(sam.heading, heading_of(flat)).abs(); let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2); let next = sam.pos + ahead * step; - // The column ahead: a wall or a drop the grid missed turns the vehicle back to the planner. + // The column and chassis clearance ahead: a wall, low ceiling or a change after planning + // turns the vehicle back to the planner. Unknown voxels remain a wait, never a drive. let Some(y) = ground.at(next, now) else { if route.wait_ahead() { out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::NoMap.text())); @@ -156,6 +157,23 @@ impl Sim { } continue; }; + match route.clearance(ground, next, now) { + route::Clearance::Clear => {} + route::Clearance::Pending => { + if route.wait_ahead() { + out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::NoMap.text())); + routes.map.remove(&sam.id); + sam.dest = None; + sam.state = SamState::Deploying; + sam.march = sam.kind.march_ticks(); + } + continue; + } + route::Clearance::Blocked => { + route.blocked_ahead(next, sam.pos); + continue; + } + } route.ahead_ok(); let rise = y - sam.pos.y; if rise > route::STEP_UP || rise < -route::STEP_DOWN { diff --git a/airdef-mod/src/sim/route.rs b/airdef-mod/src/sim/route.rs index abfe5e6..45eb4e5 100644 --- a/airdef-mod/src/sim/route.rs +++ b/airdef-mod/src/sim/route.rs @@ -64,6 +64,44 @@ pub(super) enum Drive { Fail(Why), } +/// Result of checking the vehicle's clearance at its next position. +pub(super) enum Clearance { + Clear, + Blocked, + Pending, +} + +/// Conservative chassis envelope. The circular footprint is the circumscribed plan-view of the +/// folded vehicle; it makes turns safe without adding heading to the bounded A* state. +#[derive(Clone, Copy)] +struct Chassis { + radius: f64, + height: f64, +} + +impl Chassis { + fn for_kind(kind: SamKind) -> Self { + let (radius, height) = match kind.spec().family { + Family::Trainable => (2.25, 3.0), + Family::Gun => (1.75, 3.0), + Family::Turret => (2.0, 3.25), + Family::Vertical => (2.75, 3.5), + Family::Static => (2.0, 3.0), + Family::Truck => (1.75, 2.75), + Family::Light => (1.25, 2.5), + }; + Self { radius, height } + } + + /// Integer voxel sample points inside the conservative footprint. + fn samples(self) -> Vec<(i32, i32)> { + let r = self.radius.ceil() as i32; + (-r..=r).flat_map(|x| (-r..=r).filter_map(move |z| { + ((x * x + z * z) as f64 <= self.radius * self.radius + 0.5).then_some((x, z)) + })).collect() + } +} + #[derive(Clone, Copy)] struct Info { h: f32, @@ -103,6 +141,7 @@ pub(super) struct Route { slack: f64, cell: i32, slope: f64, + chassis: Chassis, start: (i32, i32), lo: (i32, i32), hi: (i32, i32), @@ -131,14 +170,15 @@ pub(super) struct Route { } impl Route { - pub(super) fn new(pos: DVec3, dest: DVec3, slack: f64, slope: f64, now: u64) -> Self { + pub(super) fn new(pos: DVec3, dest: DVec3, slack: f64, kind: SamKind, now: u64) -> Self { let dist = horizontal(dest - pos).length(); let cell = if dist <= 400.0 { 4 } else if dist <= 900.0 { 6 } else { 8 }; let mut r = Self { dest, slack, cell, - slope, + slope: kind.climbs(), + chassis: Chassis::for_kind(kind), start: (0, 0), lo: (0, 0), hi: (0, 0), @@ -232,10 +272,50 @@ impl Route { } } }; + if verdict.ok { + match self.clearance_at(ground, DVec3::new(f64::from(x) + 0.5, f64::from(verdict.h), f64::from(z) + 0.5), now) { + Clearance::Clear => {} + Clearance::Blocked => { + let blocked = Info { h: verdict.h, ok: false }; + self.cells.insert(c, blocked); + return Eval::Bad; + } + Clearance::Pending => return Eval::Pending, + } + } self.cells.insert(c, verdict); if verdict.ok { Eval::Ok(verdict.h) } else { Eval::Bad } } + /// Check a chassis-sized footprint against sampled support columns and voxel headroom. + /// Unknown columns or sections are pending so neither search nor motion assumes they are open. + fn clearance_at(&self, ground: &mut Ground, pos: DVec3, now: u64) -> Clearance { + let base = pos.y; + let mut pending = false; + for (dx, dz) in self.chassis.samples() { + let x = pos.x.floor() as i32 + dx; + let z = pos.z.floor() as i32 + dz; + let Some(column) = ground.column(x, z, 1024) else { pending = true; continue }; + let Some(h) = column else { return Clearance::Blocked }; + let dh = h - base; + if dh > STEP_UP || dh < -STEP_DOWN { + return Clearance::Blocked; + } + let top = DVec3::new(f64::from(x) + 0.5, h + 0.05, f64::from(z) + 0.5); + match ground.world.sweep(top, top + DVec3::Y * self.chassis.height, false, now) { + world::Sweep::Clear => {} + world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => pending = true, + world::Sweep::Unknown { .. } | world::Sweep::Hit { .. } => return Clearance::Blocked, + } + } + if pending { Clearance::Pending } else { Clearance::Clear } + } + + /// Recheck the envelope at the live position, since obstacles may change after planning. + pub(super) fn clearance(&self, ground: &mut Ground, pos: DVec3, now: u64) -> Clearance { + self.clearance_at(ground, pos, now) + } + /// Relaxes the edge from the opened cell `c` to `nb`; parks it when the land between isn't known yet. fn relax(&mut self, ground: &mut Ground, now: u64, c: (i32, i32), nb: (i32, i32)) { if nb.0 < self.lo.0 || nb.1 < self.lo.1 || nb.0 > self.hi.0 || nb.1 > self.hi.1 { @@ -305,7 +385,7 @@ impl Route { } } if now >= self.retry_at && !self.parked.is_empty() { - self.retry_at = now + 6; + self.retry_at = now + 1; let parked = std::mem::take(&mut self.parked); for (k, &(c, nb)) in parked.iter().enumerate() { // The game loads the chunk of the first few, if it loads on request. @@ -336,6 +416,9 @@ impl Route { for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)] { self.relax(ground, now, c, (c.0 + di, c.1 + dj)); } + // Let pending footprint queries settle before opening a broad, less useful frontier. + // Otherwise three trucks can saturate the shared world budget without ever moving. + if !self.parked.is_empty() { break; } if self.expanded >= MAX_NODES { self.settle(now); return; @@ -505,13 +588,13 @@ mod tests { } /// Drives a Patriot from the origin to `to` over land of the given heights (water: a height over a lower floor). - fn march i32, B: Fn(i32, i32) -> i32>(height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) { + fn march_kind i32, B: Fn(i32, i32) -> i32>(kind: SamKind, height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) { let src = Src { height, floor }; let mut sim = Sim::new(3); sim.damage = false; let mut ground = Ground::default(); let id = sim.id(); - sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.5, 64.0, 0.5), 0.0)); + sim.sams.push(Sam::new(id, kind, DVec3::new(0.5, 64.0, 0.5), 0.0)); let mut out = Out::default(); for _ in 0..3 { sim.tick += 1; @@ -538,6 +621,10 @@ mod tests { (sim, out, used) } + fn march i32, B: Fn(i32, i32) -> i32>(height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) { + march_kind(SamKind::Patriot, height, floor, to, ticks) + } + // A wall across the way, with a gap far to one side: the vehicle goes round and arrives. #[test] fn a_wall_with_a_gap_is_driven_round() { @@ -572,8 +659,27 @@ mod tests { // Open ground: straight on, arrives. #[test] fn open_ground_is_driven_straight() { - let (sim, _, _) = march(|_, _| 64, |_, _| 64, DVec3::new(100.0, 64.0, 30.5), 1500); + let (sim, out, _) = march(|_, _| 64, |_, _| 64, DVec3::new(100.0, 64.0, 30.5), 1500); let s = &sim.sams[0]; - assert!(!s.moving() && s.pos.distance(DVec3::new(100.0, 64.0, 30.5)) < 3.0, "{:?}", s.pos); + let route = sim.routes.map.get(&s.id).map(|r| (r.expanded, r.parked.len(), r.open.len(), r.cells.len())); + assert!(!s.moving() && s.pos.distance(DVec3::new(100.0, 64.0, 30.5)) < 3.0, "pos={:?} state={:?} messages={:?} route={route:?}", s.pos, s.state, out.messages); + } + + // A point-column route fits through this one-block slit, but the S-400 chassis does not. + #[test] + fn s400_rejects_a_one_block_corridor() { + let walls = |x: i32, z: i32| if (-500..500).contains(&x) && z != 0 { 96 } else { 64 }; + let (sim, out, _) = march_kind(SamKind::S400, walls, walls, DVec3::new(60.5, 64.0, 0.5), 4000); + assert!(!sim.sams[0].moving(), "{:?}", sim.sams[0].state); + assert!(sim.sams[0].pos.x < 20.0, "narrow passage was traversed: {:?}", sim.sams[0].pos); + assert!(out.messages.iter().any(|m| m.contains("прохода нет") || m.contains("не проехать")), "{:?}", out.messages); + } + + // A broad, level lane remains driveable for the same large vehicle. + #[test] + fn s400_uses_a_wide_corridor() { + let walls = |x: i32, z: i32| if (-500..500).contains(&x) && !(-6..=6).contains(&z) { 96 } else { 64 }; + let (sim, _, _) = march_kind(SamKind::S400, walls, walls, DVec3::new(60.5, 64.0, 0.5), 5000); + assert!(!sim.sams[0].moving() && sim.sams[0].pos.distance(DVec3::new(60.5, 64.0, 0.5)) < 3.0, "pos={:?} state={:?}", sim.sams[0].pos, sim.sams[0].state); } }