Squashed from work: - docs: button shader annotations — widgets/button.rs complete + splash demo buttons - tweaker: the Shader tab shows the pinned widget's ANIMATOR STATES as little posed swatches under the well — one per tr - widgets: the glass stops reading draw_pass.time, so glass apps idle again - render, sim, platform: what a strategy round needs underneath - draw, platform: overlays now composite above content that uses draw_depth - asset: mp4 sample index for range-streaming, chat tools, import profiles - platform: native file and save dialogs, in-house on all three desktops - sim: a per-unit decision hook — one commandable unit can think for itself, before the kit steers - makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026 - libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly - zero-warning sweep, round two — the first full-workspace pass - zero-warning sweep, round three — the model lanes and the deep examples - zero-warning sweep, round four — the last stragglers
4987 lines
210 KiB
Rust
4987 lines
210 KiB
Rust
//! Walking an imported level: real-triangle collision plus an autonomous
|
||
//! first-person walker.
|
||
//!
|
||
//! Imported Doom/Quake/Duke maps are one big static mesh. The renderer's
|
||
//! prop collider (`voxel_collider_boxes`) is a low-res box decomposition
|
||
//! built for props — for a 20 000-triangle level it collapses to a few
|
||
//! dozen boxes, and a walker standing on a box top stands in mid-air. So
|
||
//! this module probes the LEVEL'S OWN TRIANGLES through [`MeshRaycaster`]:
|
||
//! the floor under your feet is the triangle you are actually standing on.
|
||
//!
|
||
//! Nothing here touches `Cx`, the renderer or the asset crates: a level is
|
||
//! positions + indices, and a walk step is geometry. That keeps it usable
|
||
//! by the VJ's map slot AND by the game sandbox's NPCs, and unit-testable
|
||
//! against synthetic triangle soups.
|
||
|
||
use crate::ao::MeshRaycaster;
|
||
use makepad_draw::*;
|
||
|
||
/// Which axis the source considers "up". Imported packs are Y-up (the
|
||
/// importer converts), but a raw Quake/Build mesh is Z-up and would walk on
|
||
/// the walls if taken at face value.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub enum UpAxis {
|
||
#[default]
|
||
Y,
|
||
Z,
|
||
}
|
||
|
||
/// What a floor triangle IS, for walkers that care. Doom's damaging
|
||
/// sectors and Quake's lava/slime brushes are floors you can stand on and
|
||
/// should not: the tour keeps to clean ground while it has the choice.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub enum SurfaceKind {
|
||
#[default]
|
||
Floor,
|
||
/// Damaging ground (nukage, lava, slime): walkable, avoided.
|
||
Hazard,
|
||
/// Water and other non-damaging liquid: crossed without complaint.
|
||
Liquid,
|
||
}
|
||
|
||
/// A level's collision geometry: its triangles, indexed for fast probes.
|
||
pub struct LevelCollision {
|
||
caster: MeshRaycaster,
|
||
min: Vec3f,
|
||
max: Vec3f,
|
||
/// Per-triangle surface kind, when the source could classify them
|
||
/// (importer `hazard_N` nodes, or a material-name heuristic). Empty
|
||
/// means "everything is plain floor".
|
||
kinds: Vec<SurfaceKind>,
|
||
}
|
||
|
||
/// A downward probe result.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct FloorHit {
|
||
/// Height of the surface (world Y).
|
||
pub y: f32,
|
||
/// How steep it is: 1 = flat floor, 0 = vertical wall.
|
||
pub flatness: f32,
|
||
/// What the surface is — the walker avoids standing on hazards.
|
||
pub kind: SurfaceKind,
|
||
}
|
||
|
||
impl LevelCollision {
|
||
/// From a packed vertex stream (`stride` floats per vertex, position
|
||
/// first — the engine's `MODEL_VERTEX_FLOATS` layout) plus indices.
|
||
pub fn from_packed(
|
||
vertices: &[f32],
|
||
stride: usize,
|
||
indices: &[u32],
|
||
up: UpAxis,
|
||
) -> Option<LevelCollision> {
|
||
if stride < 3 || vertices.len() < stride || indices.len() < 3 {
|
||
return None;
|
||
}
|
||
let positions: Vec<Vec3f> = vertices
|
||
.chunks_exact(stride)
|
||
.map(|v| match up {
|
||
UpAxis::Y => vec3f(v[0], v[1], v[2]),
|
||
// Z-up → Y-up, keeping the handedness the renderer expects.
|
||
UpAxis::Z => vec3f(v[0], v[2], -v[1]),
|
||
})
|
||
.collect();
|
||
Some(Self::from_positions(positions, indices.to_vec()))
|
||
}
|
||
|
||
pub fn from_positions(positions: Vec<Vec3f>, indices: Vec<u32>) -> LevelCollision {
|
||
let mut min = vec3f(f32::MAX, f32::MAX, f32::MAX);
|
||
let mut max = vec3f(f32::MIN, f32::MIN, f32::MIN);
|
||
for p in &positions {
|
||
min.x = min.x.min(p.x);
|
||
min.y = min.y.min(p.y);
|
||
min.z = min.z.min(p.z);
|
||
max.x = max.x.max(p.x);
|
||
max.y = max.y.max(p.y);
|
||
max.z = max.z.max(p.z);
|
||
}
|
||
// A hair of padding so probes starting exactly on the boundary
|
||
// (the scan starts at the ceiling) are not clipped away.
|
||
let pad = vec3f(0.01, 0.01, 0.01);
|
||
LevelCollision {
|
||
caster: MeshRaycaster::new(positions, indices, min - pad, max + pad),
|
||
min,
|
||
max,
|
||
kinds: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Attach per-triangle surface kinds (one entry per triangle, in index
|
||
/// order). Sources: the importer's `hazard_N` nodes, or a flat/material
|
||
/// name heuristic. Without them every floor is plain [`SurfaceKind::Floor`].
|
||
pub fn with_kinds(mut self, kinds: Vec<SurfaceKind>) -> LevelCollision {
|
||
if kinds.len() == self.caster.tri_count() {
|
||
self.kinds = kinds;
|
||
}
|
||
self
|
||
}
|
||
|
||
pub fn kind_of(&self, tri: u32) -> SurfaceKind {
|
||
self.kinds.get(tri as usize).copied().unwrap_or_default()
|
||
}
|
||
|
||
/// True when any surface kinds are known (the tour can then avoid
|
||
/// hazards; otherwise it treats every floor alike).
|
||
pub fn has_kinds(&self) -> bool {
|
||
!self.kinds.is_empty()
|
||
}
|
||
|
||
pub fn bounds(&self) -> (Vec3f, Vec3f) {
|
||
(self.min, self.max)
|
||
}
|
||
|
||
pub fn triangles(&self) -> usize {
|
||
self.caster.tri_count()
|
||
}
|
||
|
||
/// Nearest surface below `from`, within `max_drop`. `from` should be
|
||
/// the probe START (feet plus the step-up allowance), so a walker can
|
||
/// find ledges above its feet as well as the ground below them.
|
||
pub fn floor_below(&self, from: Vec3f, max_drop: f32) -> Option<FloorHit> {
|
||
let (t, tri) = self.caster.nearest_hit(from, vec3f(0.0, -1.0, 0.0), max_drop)?;
|
||
let (a, b, c) = self.caster.triangle(tri);
|
||
// Winding is not trustworthy in imported maps; flatness is the
|
||
// magnitude of the normal's up component.
|
||
let (e1, e2) = (b - a, c - a);
|
||
let n = vec3f(
|
||
e1.y * e2.z - e1.z * e2.y,
|
||
e1.z * e2.x - e1.x * e2.z,
|
||
e1.x * e2.y - e1.y * e2.x,
|
||
);
|
||
let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt().max(1e-9);
|
||
Some(FloorHit {
|
||
y: from.y - t,
|
||
flatness: (n.y / len).abs(),
|
||
kind: self.kind_of(tri),
|
||
})
|
||
}
|
||
|
||
/// Nearest surface above `from`, within `max_rise` (a ceiling proves an
|
||
/// interior; the outside of a map has open sky above it).
|
||
pub fn ceiling_above(&self, from: Vec3f, max_rise: f32) -> Option<f32> {
|
||
let (t, _) = self.caster.nearest_hit(from, vec3f(0.0, 1.0, 0.0), max_rise)?;
|
||
Some(from.y + t)
|
||
}
|
||
|
||
/// Does the level's own geometry cut the straight line from `from` to
|
||
/// `to`? A line of sight, a shot, a thrown thing: anything that travels
|
||
/// point to point through a streamed map has to ask THIS, because the
|
||
/// map's triangles are not bodies in the sim — a body-only ray cast
|
||
/// passes clean through every wall of the level.
|
||
pub fn segment_blocked(&self, from: Vec3f, to: Vec3f) -> bool {
|
||
let delta = to - from;
|
||
let dist = (delta.x * delta.x + delta.y * delta.y + delta.z * delta.z).sqrt();
|
||
if dist < 1e-6 {
|
||
return false;
|
||
}
|
||
self.caster.any_hit(from, delta * (1.0 / dist), dist)
|
||
}
|
||
|
||
/// Nearest surface along `dir` (unit length) from `from`, within
|
||
/// `max`: `(distance, unit normal)`, the normal flipped to face back
|
||
/// along the ray — winding is not trustworthy in imported maps, and a
|
||
/// suspension spring wants the push-back direction, not the authored
|
||
/// one.
|
||
pub fn ray_hit(&self, from: Vec3f, dir: Vec3f, max: f32) -> Option<(f32, Vec3f)> {
|
||
let (t, tri) = self.caster.nearest_hit(from, dir, max)?;
|
||
let (a, b, c) = self.caster.triangle(tri);
|
||
let (e1, e2) = (b - a, c - a);
|
||
let mut n = vec3f(
|
||
e1.y * e2.z - e1.z * e2.y,
|
||
e1.z * e2.x - e1.x * e2.z,
|
||
e1.x * e2.y - e1.y * e2.x,
|
||
);
|
||
let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt();
|
||
n = if len > 1.0e-9 { n * (1.0 / len) } else { dir * -1.0 };
|
||
if n.x * dir.x + n.y * dir.y + n.z * dir.z > 0.0 {
|
||
n = n * -1.0;
|
||
}
|
||
Some((t, n))
|
||
}
|
||
|
||
/// The floor a body near `(x, near_y, z)` belongs on.
|
||
///
|
||
/// Indoors a single probe from far above lands on the CEILING (the
|
||
/// map_actors lesson), so this walks every surface in the column from
|
||
/// the sky down, keeps the ones with at least `head_room` of space
|
||
/// above them (a floor you could stand on), and returns the standable
|
||
/// floor of the room containing `near_y` — else the nearest one. A body
|
||
/// spawned inside a raised slab is lifted onto it; one spawned in the
|
||
/// air belongs to the floor below it. `None` = no floor in this column
|
||
/// (outside the map).
|
||
pub fn ground_under(&self, x: f32, z: f32, near_y: f32, head_room: f32) -> Option<f32> {
|
||
let mut surfaces: Vec<f32> = Vec::new();
|
||
let mut probe = near_y + 200.0;
|
||
for _ in 0..24 {
|
||
let Some(hit) = self.floor_below(vec3f(x, probe, z), 500.0) else { break };
|
||
surfaces.push(hit.y);
|
||
probe = hit.y - 0.02;
|
||
}
|
||
// Standable floors: enough room between this surface and the one
|
||
// above it (the topmost surface has the sky).
|
||
let mut floors: Vec<(f32, f32)> = Vec::new(); // (floor, space above)
|
||
for (i, &y) in surfaces.iter().enumerate() {
|
||
let space = if i == 0 { f32::INFINITY } else { surfaces[i - 1] - y };
|
||
if space >= head_room {
|
||
floors.push((y, space));
|
||
}
|
||
}
|
||
// The room containing near_y wins outright; else nearest floor.
|
||
floors
|
||
.iter()
|
||
.find(|(y, space)| near_y >= *y && near_y < *y + *space)
|
||
.or_else(|| {
|
||
floors.iter().min_by(|a, b| {
|
||
(a.0 - near_y)
|
||
.abs()
|
||
.partial_cmp(&(b.0 - near_y).abs())
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
})
|
||
})
|
||
.map(|(y, _)| *y)
|
||
}
|
||
|
||
/// The room around `at`: `(headroom, span)` — floor-to-ceiling height
|
||
/// over the floor `at` belongs to, and the narrowest straight
|
||
/// horizontal line through the spot at door height (eight directions,
|
||
/// opposite pairs summed). `None` when there is no ceiling: open
|
||
/// ground, where anything fits.
|
||
pub fn room_at(&self, at: Vec3f) -> Option<(f32, f32)> {
|
||
let floor = self.ground_under(at.x, at.z, at.y, 1.0)?;
|
||
let ceiling = self.ceiling_above(vec3f(at.x, floor + 0.05, at.z), 60.0)?;
|
||
let probe = vec3f(at.x, floor + 1.0, at.z);
|
||
let reach = 30.0;
|
||
let mut dist = [reach; 8];
|
||
for (i, d) in dist.iter_mut().enumerate() {
|
||
let a = std::f32::consts::TAU * i as f32 / 8.0;
|
||
if let Some((t, _)) = self.ray_hit(probe, vec3f(a.sin(), 0.0, -a.cos()), reach) {
|
||
*d = t;
|
||
}
|
||
}
|
||
let span = (0..4)
|
||
.map(|i| dist[i] + dist[i + 4])
|
||
.fold(f32::INFINITY, f32::min);
|
||
Some((ceiling - floor, span))
|
||
}
|
||
|
||
/// Are any of these horizontal moves cut by the level? Each pair is a
|
||
/// start/end ground point of one tracked point of a body's footprint
|
||
/// (a wheeled body sweeps its corners and edge midpoints through here).
|
||
/// Rays run at each `height` above `base` — the HIGHER of the two floor
|
||
/// heights, the same rule as [`Self::path_blocked`], so climbing a
|
||
/// step's own riser never reads as a wall.
|
||
pub fn moves_blocked(&self, moves: &[(Vec3f, Vec3f)], base: f32, heights: &[f32]) -> bool {
|
||
for (from, to) in moves {
|
||
let delta = vec3f(to.x - from.x, 0.0, to.z - from.z);
|
||
let dist = (delta.x * delta.x + delta.z * delta.z).sqrt();
|
||
if dist < 1.0e-6 {
|
||
continue;
|
||
}
|
||
let dir = vec3f(delta.x / dist, 0.0, delta.z / dist);
|
||
for &h in heights {
|
||
let origin = vec3f(from.x, base + h, from.z);
|
||
// Barely past the move itself: a fatter margin re-blocked
|
||
// the move AWAY from a wall the body was already against,
|
||
// which is how a car got welded to the plaster it hit.
|
||
if self.caster.any_hit(origin, dir, dist + 0.02) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Is the straight walk from `from` to `to` (both FEET positions)
|
||
/// obstructed? The body is approximated by rays at knee and chest
|
||
/// height, plus the same pair offset by ±`radius` sideways, so a
|
||
/// doorway narrower than the body reads as blocked.
|
||
///
|
||
/// The rays are measured above the HIGHER of the two floors. Probing a
|
||
/// rising step this lifts them into the destination's frame, so a
|
||
/// staircase's own next treads (and any thin riser lip an importer
|
||
/// leaves poking above them) never read as walls — the rays are cast
|
||
/// `dist + radius` long, and on a flight steeper than the knee height
|
||
/// per body-radius a start-relative knee ray slams into the tread AFTER
|
||
/// the destination, which is how a perfectly legal staircase used to
|
||
/// have no graph edges at all. A genuine wall is taller than knee
|
||
/// height above EITHER floor and still blocks; flat walks and drops
|
||
/// (probed from the upper floor) are unchanged.
|
||
pub fn path_blocked(
|
||
&self,
|
||
from: Vec3f,
|
||
to: Vec3f,
|
||
radius: f32,
|
||
step_up: f32,
|
||
height: f32,
|
||
) -> bool {
|
||
let delta = vec3f(to.x - from.x, 0.0, to.z - from.z);
|
||
let dist = (delta.x * delta.x + delta.z * delta.z).sqrt();
|
||
if dist < 1e-6 {
|
||
return false;
|
||
}
|
||
let dir = vec3f(delta.x / dist, 0.0, delta.z / dist);
|
||
let side = vec3f(-dir.z, 0.0, dir.x);
|
||
let base = from.y.max(to.y);
|
||
// Knee starts above the step-up allowance: a ledge you can climb is
|
||
// not a wall, exactly as the step logic assumes.
|
||
let heights = [knee_height(step_up, height), height * 0.85];
|
||
let lanes = [-radius, 0.0, radius];
|
||
for h in heights {
|
||
for l in lanes {
|
||
let origin = vec3f(from.x + side.x * l, base + h, from.z + side.z * l);
|
||
if self.caster.any_hit(origin, dir, dist + radius) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Is there at least `clear` of empty space all round the body? The
|
||
/// walker keeps this much between its eye and any wall so the camera's
|
||
/// near plane never pokes through geometry.
|
||
///
|
||
/// The lowest ray sits at the SAME knee height [`path_blocked`] probes
|
||
/// with. The two must agree: any gap between them is a band of wall
|
||
/// tops the walk probe offers and the landing check refuses — the body
|
||
/// walks up to the ledge and then refuses to stand, forever (the E1M1
|
||
/// courtyard rim bug).
|
||
pub fn clearance_ok(&self, feet: Vec3f, clear: f32, step_up: f32, height: f32) -> bool {
|
||
if clear <= 0.0 {
|
||
return true;
|
||
}
|
||
let heights = [knee_height(step_up, height), height * 0.55, height * 0.95];
|
||
for h in heights {
|
||
let origin = vec3f(feet.x, feet.y + h, feet.z);
|
||
for i in 0..8 {
|
||
let a = std::f32::consts::TAU * i as f32 / 8.0;
|
||
if self.caster.any_hit(origin, vec3f(a.sin(), 0.0, -a.cos()), clear) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Free walking distance along `dir` from `feet`, stopping at a wall,
|
||
/// a step too tall, or the edge of the floor. Marched in body-radius
|
||
/// steps: this is the "how open is this heading" probe.
|
||
pub fn free_run(&self, feet: Vec3f, yaw: f32, cfg: &WalkerConfig) -> f32 {
|
||
let dir = yaw_forward(yaw);
|
||
let step = (cfg.radius * 1.5).max(0.05);
|
||
let mut at = feet;
|
||
let mut travelled = 0.0;
|
||
while travelled < cfg.probe_ahead {
|
||
let want = vec3f(at.x + dir.x * step, at.y, at.z + dir.z * step);
|
||
if self.path_blocked(at, want, cfg.radius, cfg.step_up, cfg.height) {
|
||
break;
|
||
}
|
||
let Some(floor) = self.floor_below(
|
||
vec3f(want.x, want.y + cfg.step_up, want.z),
|
||
cfg.step_up + cfg.fall_limit,
|
||
) else {
|
||
break; // void ahead
|
||
};
|
||
if floor.y - at.y > cfg.step_up + STEP_EPS || at.y - floor.y > cfg.fall_limit {
|
||
break;
|
||
}
|
||
at = vec3f(want.x, floor.y, want.z);
|
||
travelled += step;
|
||
}
|
||
travelled
|
||
}
|
||
|
||
/// How much of the free run along `yaw` crosses hazardous floor
|
||
/// (nukage, lava, slime). Zero when the way is clean — the heading
|
||
/// picker subtracts this so a safe bridge always beats a shortcut
|
||
/// through the ooze.
|
||
pub fn hazard_run(&self, feet: Vec3f, yaw: f32, cfg: &WalkerConfig) -> f32 {
|
||
let dir = yaw_forward(yaw);
|
||
let step = (cfg.radius * 1.5).max(0.05);
|
||
let mut at = feet;
|
||
let (mut travelled, mut bad) = (0.0, 0.0);
|
||
while travelled < cfg.probe_ahead {
|
||
let want = vec3f(at.x + dir.x * step, at.y, at.z + dir.z * step);
|
||
if self.path_blocked(at, want, cfg.radius, cfg.step_up, cfg.height) {
|
||
break;
|
||
}
|
||
let Some(floor) =
|
||
self.floor_below(vec3f(want.x, want.y + cfg.step_up, want.z), cfg.step_up + cfg.fall_limit)
|
||
else {
|
||
break;
|
||
};
|
||
if floor.y - at.y > cfg.step_up + STEP_EPS || at.y - floor.y > cfg.fall_limit {
|
||
break;
|
||
}
|
||
if floor.kind == SurfaceKind::Hazard {
|
||
bad += step;
|
||
}
|
||
at = vec3f(want.x, floor.y, want.z);
|
||
travelled += step;
|
||
}
|
||
bad
|
||
}
|
||
|
||
/// A standable spot INSIDE the level: floor under the feet, a ceiling
|
||
/// over the head, and room to stand. Scans a coarse grid and prefers
|
||
/// the most open interior spot nearest the middle of the map.
|
||
///
|
||
/// This is the fallback for maps whose catalog manifest carries no
|
||
/// `player_start` anchor — today, every classic map.
|
||
pub fn interior_start(&self, cfg: &WalkerConfig) -> Option<Vec3f> {
|
||
let (min, max) = self.bounds();
|
||
let steps = 24;
|
||
let mut best: Option<(f32, Vec3f)> = None;
|
||
for iz in 0..=steps {
|
||
for ix in 0..=steps {
|
||
let x = min.x + (max.x - min.x) * ix as f32 / steps as f32;
|
||
let z = min.z + (max.z - min.z) * iz as f32 / steps as f32;
|
||
for (floor, kind) in self.surfaces_in_column(x, z, cfg) {
|
||
if kind == SurfaceKind::Hazard {
|
||
continue; // never spawn the tour in the nukage
|
||
}
|
||
let feet = vec3f(x, floor, z);
|
||
// Room to stand AND somewhere to walk.
|
||
let open: f32 = (0..4)
|
||
.map(|q| {
|
||
self.free_run(feet, q as f32 * std::f32::consts::FRAC_PI_2, cfg)
|
||
})
|
||
.sum();
|
||
if open < cfg.radius * 4.0 {
|
||
continue;
|
||
}
|
||
if best.is_none_or(|(cur, _)| open > cur) {
|
||
best = Some((open, feet));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
best.map(|(_, p)| p)
|
||
}
|
||
|
||
/// Floors in the column at `(x, z)` that a body fits on with a ceiling
|
||
/// above it — i.e. interior floors, never the outside of the roof.
|
||
fn surfaces_in_column(&self, x: f32, z: f32, cfg: &WalkerConfig) -> Vec<(f32, SurfaceKind)> {
|
||
let (min, max) = self.bounds();
|
||
// (height, kind, has a ceiling over it)
|
||
let mut raw: Vec<(f32, SurfaceKind, bool)> = Vec::new();
|
||
let mut y = max.y + 0.05;
|
||
// March down through every surface in this column.
|
||
for _ in 0..32 {
|
||
let Some(hit) = self.floor_below(vec3f(x, y, z), y - min.y + 0.1) else {
|
||
break;
|
||
};
|
||
if hit.flatness >= 0.5 {
|
||
let roofed = self
|
||
.ceiling_above(vec3f(x, hit.y + 0.02, z), MAX_HEADROOM)
|
||
.is_some_and(|c| c - hit.y >= cfg.height);
|
||
raw.push((hit.y, hit.kind, roofed));
|
||
}
|
||
y = hit.y - 1e-3;
|
||
if y < min.y - 0.05 {
|
||
break;
|
||
}
|
||
}
|
||
// Surfaces come off the march top-down. A ceiling over the head
|
||
// proves we are indoors and the surface is a floor. Open sky above
|
||
// means one of two things — the OUTSIDE OF A ROOF, or genuine open
|
||
// ground — and what tells them apart is whether the column holds
|
||
// another surface underneath: a roof always has its room below it,
|
||
// a courtyard has nothing. (The old rule was "open sky counts only
|
||
// in the lower half of the map", which threw away every outdoor
|
||
// yard on a hill and cut classic maps into disconnected wings.)
|
||
raw.iter()
|
||
.enumerate()
|
||
.filter(|(i, (y, _, roofed))| {
|
||
*roofed || *i + 1 == raw.len() || *y < (min.y + max.y) * 0.5
|
||
})
|
||
.map(|(_, (y, kind, _))| (*y, *kind))
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// How far above the head a ceiling may be and still prove "indoors".
|
||
const MAX_HEADROOM: f32 = 8.0;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Surface kinds out of a GLB
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Classify every triangle of a level GLB as floor, hazard or liquid.
|
||
///
|
||
/// The importer's contract is a `hazard_N` node carrying
|
||
/// `extras {kind:"hazard", damage, flat, liquid}`; older files (every
|
||
/// classic map published before that lane) carry only the source engine's
|
||
/// flat/material name, so the name heuristic stays as a fallback.
|
||
///
|
||
/// Triangles are counted in EXACTLY the order [`crate::StaticModel`] packs
|
||
/// them — nodes in file order, primitives in mesh order, geometry under an
|
||
/// animated node left out. `expect_tris` is that model's triangle count and
|
||
/// the contract check: a walk that does not reproduce it has drifted from
|
||
/// the loader, and `None` (every floor plain) is the honest answer, never a
|
||
/// mislabelled map.
|
||
pub fn surface_kinds_from_glb(bytes: &[u8], expect_tris: usize) -> Option<Vec<SurfaceKind>> {
|
||
use crate::skin::{JsonParser, Val};
|
||
if bytes.len() < 12 || &bytes[0..4] != b"glTF" {
|
||
return None;
|
||
}
|
||
let mut json_chunk: Option<&[u8]> = None;
|
||
let mut at = 12;
|
||
while at + 8 <= bytes.len() {
|
||
let len = u32::from_le_bytes(bytes[at..at + 4].try_into().ok()?) as usize;
|
||
if &bytes[at + 4..at + 8] == b"JSON" {
|
||
json_chunk = bytes.get(at + 8..at + 8 + len);
|
||
}
|
||
at += 8 + len + (4 - len % 4) % 4;
|
||
}
|
||
let json = JsonParser::parse(json_chunk?).ok()?;
|
||
let nodes = json.get("nodes").map(|n| n.arr()).unwrap_or(&[]);
|
||
if nodes.is_empty() {
|
||
return None;
|
||
}
|
||
let mut parents: Vec<Option<usize>> = vec![None; nodes.len()];
|
||
for (p, n) in nodes.iter().enumerate() {
|
||
if let Some(children) = n.get("children") {
|
||
for c in children.arr() {
|
||
if let Some(ci) = c.usize() {
|
||
if ci < parents.len() {
|
||
parents[ci] = Some(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// A node the loader turns into a moving part (`extras.states` naming at
|
||
// least two states) takes its geometry OUT of the static stream.
|
||
let animated: Vec<bool> = nodes
|
||
.iter()
|
||
.map(|n| {
|
||
n.get("extras")
|
||
.and_then(|e| e.get("states"))
|
||
.map(|s| s.arr().iter().filter(|v| v.str().is_some()).count() >= 2)
|
||
.unwrap_or(false)
|
||
&& n.get("name").and_then(Val::str).is_some()
|
||
})
|
||
.collect();
|
||
let has_animations = json.get("animations").map(|a| !a.arr().is_empty()).unwrap_or(false);
|
||
// Geometry under a SKY node is the sky's, not the level's — the loader
|
||
// takes it out of the static stream (`StaticModel::sky`), so a walk that
|
||
// counts it can never reproduce the model's triangle count, and the
|
||
// whole classification is discarded as drift. Every classic map with an
|
||
// open-air area publishes one of these, which is exactly the set of maps
|
||
// that also has a nukage pool to mark.
|
||
let sky: Vec<bool> = nodes
|
||
.iter()
|
||
.map(|n| {
|
||
let Some(extras) = n.get("extras") else { return false };
|
||
let marked = extras.get("kind").and_then(Val::str) == Some("sky")
|
||
|| n.get("name").and_then(Val::str) == Some("sky");
|
||
// Same contract as the loader: a sky node without a projection
|
||
// it understands stays ordinary geometry.
|
||
marked && extras.get("projection").and_then(Val::str).is_some()
|
||
})
|
||
.collect();
|
||
let owned_by_sky = |mut i: usize| -> bool {
|
||
for _ in 0..64 {
|
||
if sky[i] {
|
||
return true;
|
||
}
|
||
match parents[i] {
|
||
Some(p) => i = p,
|
||
None => break,
|
||
}
|
||
}
|
||
false
|
||
};
|
||
// The node's kind is its own, or the nearest ancestor's that has one:
|
||
// classic importers group a whole nukage sector under one node.
|
||
let kind_of_node = |mut i: usize| -> Option<SurfaceKind> {
|
||
for _ in 0..64 {
|
||
if let Some(k) = node_surface_kind(&nodes[i]) {
|
||
return Some(k);
|
||
}
|
||
match parents[i] {
|
||
Some(p) => i = p,
|
||
None => break,
|
||
}
|
||
}
|
||
None
|
||
};
|
||
let owned_by_part = |mut i: usize| -> bool {
|
||
for _ in 0..64 {
|
||
if animated[i] {
|
||
return true;
|
||
}
|
||
match parents[i] {
|
||
Some(p) => i = p,
|
||
None => break,
|
||
}
|
||
}
|
||
false
|
||
};
|
||
// Two passes: with the anim-part skip (what a level with doors packs)
|
||
// and without (what every file predating that lane packs). The one whose
|
||
// triangle count matches the loaded model is the one to trust.
|
||
let attempts: &[bool] = if has_animations { &[true, false] } else { &[false] };
|
||
for &skip_parts in attempts {
|
||
let mut kinds: Vec<SurfaceKind> = Vec::with_capacity(expect_tris);
|
||
for (ni, n) in nodes.iter().enumerate() {
|
||
let Some(mesh_index) = n.get("mesh").and_then(Val::usize) else { continue };
|
||
if skip_parts && owned_by_part(ni) {
|
||
continue;
|
||
}
|
||
if owned_by_sky(ni) {
|
||
continue;
|
||
}
|
||
let node_kind = kind_of_node(ni);
|
||
let Some(mesh) = json.get("meshes").and_then(|m| m.idx(mesh_index)) else { continue };
|
||
for prim in mesh.get("primitives").map(|p| p.arr()).unwrap_or(&[]) {
|
||
let tris = prim_triangle_count(&json, prim);
|
||
if tris == 0 {
|
||
continue;
|
||
}
|
||
let kind = node_kind
|
||
.or_else(|| prim_material_kind(&json, prim))
|
||
.unwrap_or_default();
|
||
kinds.resize(kinds.len() + tris, kind);
|
||
}
|
||
}
|
||
if kinds.len() == expect_tris && kinds.iter().any(|k| *k != SurfaceKind::Floor) {
|
||
return Some(kinds);
|
||
}
|
||
if skip_parts == has_animations && has_animations {
|
||
continue; // try the no-skip walk
|
||
}
|
||
break;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// A node's own declared surface, from the importer's `hazard_N` contract
|
||
/// (`extras {kind, damage, liquid, flat}`) or, failing that, its name.
|
||
fn node_surface_kind(node: &crate::skin::Val) -> Option<SurfaceKind> {
|
||
use crate::skin::Val;
|
||
if let Some(extras) = node.get("extras") {
|
||
let declared = extras.get("kind").and_then(Val::str).unwrap_or("");
|
||
let damage = extras.get("damage").and_then(Val::f64).unwrap_or(0.0);
|
||
let liquid = matches!(extras.get("liquid"), Some(Val::Bool(true)))
|
||
|| extras.get("liquid").and_then(Val::f64).unwrap_or(0.0) > 0.0;
|
||
if declared.eq_ignore_ascii_case("hazard") {
|
||
// A declared hazard with no damage that calls itself a liquid is
|
||
// just water: crossed, not avoided.
|
||
return Some(if damage <= 0.0 && liquid {
|
||
SurfaceKind::Liquid
|
||
} else {
|
||
SurfaceKind::Hazard
|
||
});
|
||
}
|
||
if damage > 0.0 {
|
||
return Some(SurfaceKind::Hazard);
|
||
}
|
||
if let Some(flat) = extras.get("flat").and_then(Val::str) {
|
||
if let Some(k) = surface_kind_from_name(flat) {
|
||
return Some(k);
|
||
}
|
||
}
|
||
if liquid {
|
||
return Some(SurfaceKind::Liquid);
|
||
}
|
||
}
|
||
node.get("name").and_then(Val::str).and_then(surface_kind_from_name)
|
||
}
|
||
|
||
/// The material's name as a last resort — a Doom import names its material
|
||
/// after the flat, which is how every map published before the `hazard_N`
|
||
/// contract can still be classified.
|
||
fn prim_material_kind(json: &crate::skin::Val, prim: &crate::skin::Val) -> Option<SurfaceKind> {
|
||
use crate::skin::Val;
|
||
let mi = prim.get("material").and_then(Val::usize)?;
|
||
let mat = json.get("materials").and_then(|m| m.idx(mi))?;
|
||
if let Some(k) = mat
|
||
.get("extras")
|
||
.and_then(|e| e.get("flat"))
|
||
.and_then(Val::str)
|
||
.and_then(surface_kind_from_name)
|
||
{
|
||
return Some(k);
|
||
}
|
||
mat.get("name").and_then(Val::str).and_then(surface_kind_from_name)
|
||
}
|
||
|
||
/// Flat/texture names the classic engines use for ground that hurts, and
|
||
/// for ground that is merely wet. Matched case-insensitively anywhere in the
|
||
/// name, because importers prefix and suffix freely (`doom.NUKAGE1.001`).
|
||
fn surface_kind_from_name(name: &str) -> Option<SurfaceKind> {
|
||
let n = name.to_ascii_lowercase();
|
||
// Water first: `fwater` contains neither, but `*water` in Quake and
|
||
// `water` anywhere must not be read as a hazard by a later rule.
|
||
const HAZARD: [&str; 7] = ["nukage", "slime", "lava", "blood", "acid", "sludge", "hazard"];
|
||
const LIQUID: [&str; 3] = ["water", "wtr", "swamp"];
|
||
if HAZARD.iter().any(|h| n.contains(h)) {
|
||
return Some(SurfaceKind::Hazard);
|
||
}
|
||
if LIQUID.iter().any(|l| n.contains(l)) {
|
||
return Some(SurfaceKind::Liquid);
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Triangles a primitive contributes: its index count, or its vertex count
|
||
/// when it is drawn unindexed. Only TRIANGLES mode (4, the glTF default)
|
||
/// reaches the static stream.
|
||
fn prim_triangle_count(json: &crate::skin::Val, prim: &crate::skin::Val) -> usize {
|
||
use crate::skin::Val;
|
||
let count = match prim.get("indices").and_then(Val::usize) {
|
||
Some(acc) => json
|
||
.get("accessors")
|
||
.and_then(|a| a.idx(acc))
|
||
.and_then(|a| a.get("count"))
|
||
.and_then(Val::usize)
|
||
.unwrap_or(0),
|
||
None => prim
|
||
.get("attributes")
|
||
.and_then(|a| a.get("POSITION"))
|
||
.and_then(Val::usize)
|
||
.and_then(|acc| json.get("accessors").and_then(|a| a.idx(acc)))
|
||
.and_then(|a| a.get("count"))
|
||
.and_then(Val::usize)
|
||
.unwrap_or(0),
|
||
};
|
||
count / 3
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Navigation grid
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Smallest xz cell the nav grid uses: ONE BODY per cell, i.e. two radii.
|
||
///
|
||
/// This used to be the bare number 0.5 — a Doom body's width at the Doom
|
||
/// importer's metres-per-map-unit. A map published at a different scale (a
|
||
/// Quake 1 level is 1/32, twice Doom's 1/64) then got a lattice half the
|
||
/// width of the body walking it: several sample points across one body, so
|
||
/// every ledge and jamb cut the grid into slivers, the room watershed read
|
||
/// the whole map as corridor, and the string-pull had a mandatory waypoint
|
||
/// every metre. The cell has to be the body's, or the graph is not a graph
|
||
/// of where THIS body can walk.
|
||
fn nav_cell_min(cfg: &WalkerConfig) -> f32 {
|
||
// 0.5 is the finest lattice worth probing (a Doom body's width, and the
|
||
// old fixed value); a WIDER body only ever makes it coarser.
|
||
(cfg.radius * 2.0).max(0.5)
|
||
}
|
||
/// Above this many columns the cell grows instead: a 500-unit outdoor map
|
||
/// must not turn into a hundred million probes.
|
||
const NAV_MAX_COLUMNS: usize = 40_000;
|
||
/// Hard ceiling on the growth above (a cell this big is no longer nav).
|
||
const NAV_CELL_MAX: f32 = 4.0;
|
||
/// Classic integer grid costs: a diagonal is √2 orthogonals.
|
||
const NAV_STEP_COST: u32 = 10;
|
||
const NAV_DIAG_COST: u32 = 14;
|
||
/// Entering hazardous ground costs twenty ordinary cells, so a bridge is
|
||
/// taken whenever a bridge exists and the ooze is still crossable when it
|
||
/// is the only way through.
|
||
const NAV_HAZARD_COST: u32 = 200;
|
||
/// Unreached cell marker in the flood's distance array.
|
||
const NAV_UNREACHED: u32 = u32::MAX;
|
||
|
||
/// One standable spot: a floor point at a column centre, on one of the
|
||
/// floors stacked in that column.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct NavCell {
|
||
/// Feet position — the column centre at this floor's height.
|
||
pub pos: Vec3f,
|
||
pub kind: SurfaceKind,
|
||
/// `ix + iz * nx` — which xz column this floor belongs to.
|
||
pub column: u32,
|
||
/// The door part that gates this cell, when one does. The walker asks
|
||
/// the host to open it before walking in.
|
||
pub door: Option<u16>,
|
||
/// Teleporter pad: stepping on this cell moves the body to
|
||
/// `NavGrid::teleport_target`.
|
||
pub teleport: Option<u16>,
|
||
/// This cell is in a pocket the ordinary step cannot leave — a sunken
|
||
/// nukage pool, a lift well — and its way out was linked with the
|
||
/// generous escape step. The body is allowed the same stretch while it
|
||
/// stands here, or it would refuse the edge the graph is routing it
|
||
/// along and grind along the rim instead.
|
||
pub escape: bool,
|
||
}
|
||
|
||
/// A level's walkable cells and how they connect: built ONCE per level from
|
||
/// [`LevelCollision`], then used to plan real routes.
|
||
///
|
||
/// The old walker scored twelve headings from where it stood, which makes a
|
||
/// corridor a local optimum: the longest free run is always the way it just
|
||
/// came, so it paced between two staircases forever. A graph over the whole
|
||
/// map replaces "which way looks open" with "which part of the map have I
|
||
/// not seen yet, and how do I get there".
|
||
///
|
||
/// Columns are a regular xz lattice; a column holds EVERY floor stacked in
|
||
/// it (a room over a room is two cells), which is what makes multi-storey
|
||
/// maps work. Edges are 8-neighbour, refused through walls by the same
|
||
/// knee/chest probe the walker's own step uses.
|
||
pub struct NavGrid {
|
||
cell: f32,
|
||
/// World position of column (0, 0)'s centre in x/z.
|
||
origin_x: f32,
|
||
origin_z: f32,
|
||
nx: usize,
|
||
nz: usize,
|
||
cells: Vec<NavCell>,
|
||
/// CSR over columns: column `c` owns `cells[col_start[c]..col_start[c+1]]`.
|
||
col_start: Vec<u32>,
|
||
edge_start: Vec<u32>,
|
||
edge_to: Vec<u32>,
|
||
edge_cost: Vec<u32>,
|
||
/// Which connected piece of the map each cell belongs to, and how big
|
||
/// each piece is. A classic map whose doors are still baked into the
|
||
/// static mesh is a dozen sealed rooms, and the tour has to know that.
|
||
component: Vec<u32>,
|
||
comp_size: Vec<usize>,
|
||
/// Teleporter destinations: `(feet, yaw)`, indexed by `NavCell::teleport`.
|
||
teleports: Vec<(Vec3f, f32)>,
|
||
refused: NavRefusals,
|
||
}
|
||
|
||
/// Why neighbouring cells did NOT become edges. A map the walker cannot
|
||
/// leave shows up here before it shows up on screen.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||
pub struct NavRefusals {
|
||
/// Rise over the step limit.
|
||
pub too_tall: usize,
|
||
/// The smallest rise that was still refused — on a Doom map this should
|
||
/// read well over 0.375, and a value AT 0.375 means the step rule is
|
||
/// off by a float.
|
||
pub smallest_refused_rise: f32,
|
||
/// Drop past the fall limit.
|
||
pub too_deep: usize,
|
||
/// A wall between the two centres.
|
||
pub walled: usize,
|
||
/// Edges the escape pass added to un-trap a pocket.
|
||
pub escapes: usize,
|
||
}
|
||
|
||
/// A planned leg: where the tour is going and the cells it walks through.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct NavPlan {
|
||
pub goal: u32,
|
||
/// Cells from the one AFTER the start up to and including the goal.
|
||
pub path: Vec<u32>,
|
||
/// How much of the map the plan could see from the start.
|
||
pub reachable: usize,
|
||
/// Visit count of the LEAST-trodden reachable cell. Once this rises
|
||
/// above one, everything the walker can reach has been seen and the
|
||
/// tour should cut somewhere else.
|
||
pub frontier: f32,
|
||
}
|
||
|
||
impl NavGrid {
|
||
/// Probe the level into a walkable-cell graph. Thousands of short rays:
|
||
/// this belongs on the loader thread, never in a frame.
|
||
pub fn build(level: &LevelCollision, cfg: &WalkerConfig) -> NavGrid {
|
||
let (min, max) = level.bounds();
|
||
let span_x = (max.x - min.x).max(0.001);
|
||
let span_z = (max.z - min.z).max(0.001);
|
||
let mut cell = nav_cell_min(cfg);
|
||
let (mut nx, mut nz);
|
||
loop {
|
||
nx = ((span_x / cell).ceil() as usize).max(1);
|
||
nz = ((span_z / cell).ceil() as usize).max(1);
|
||
if nx * nz <= NAV_MAX_COLUMNS || cell >= NAV_CELL_MAX {
|
||
break;
|
||
}
|
||
cell *= 1.25;
|
||
}
|
||
// Columns are CENTRES, half a cell in from the bounds. A lattice
|
||
// that starts exactly on `min` puts its first column inside the
|
||
// boundary wall, where a zero-length clearance ray reports "clear"
|
||
// and a cell appears in the masonry.
|
||
let (origin_x, origin_z) = (min.x + cell * 0.5, min.z + cell * 0.5);
|
||
let columns = nx * nz;
|
||
let mut cells: Vec<NavCell> = Vec::new();
|
||
let mut col_start: Vec<u32> = Vec::with_capacity(columns + 1);
|
||
for iz in 0..nz {
|
||
for ix in 0..nx {
|
||
col_start.push(cells.len() as u32);
|
||
let x = origin_x + ix as f32 * cell;
|
||
let z = origin_z + iz as f32 * cell;
|
||
for (y, kind) in level.surfaces_in_column(x, z, cfg) {
|
||
let feet = vec3f(x, y, z);
|
||
// The cell exists only if the BODY fits standing here:
|
||
// a capsule of the walker's radius, its full height.
|
||
if !level.clearance_ok(feet, cfg.radius, cfg.step_up, cfg.height) {
|
||
continue;
|
||
}
|
||
cells.push(NavCell {
|
||
pos: feet,
|
||
kind,
|
||
column: (ix + iz * nx) as u32,
|
||
door: None,
|
||
teleport: None,
|
||
escape: false,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
col_start.push(cells.len() as u32);
|
||
let mut grid = NavGrid {
|
||
cell,
|
||
origin_x,
|
||
origin_z,
|
||
nx,
|
||
nz,
|
||
cells,
|
||
col_start,
|
||
edge_start: Vec::new(),
|
||
edge_to: Vec::new(),
|
||
edge_cost: Vec::new(),
|
||
component: Vec::new(),
|
||
comp_size: Vec::new(),
|
||
teleports: Vec::new(),
|
||
refused: NavRefusals { smallest_refused_rise: f32::MAX, ..NavRefusals::default() },
|
||
};
|
||
grid.link(level, cfg);
|
||
grid.label_components();
|
||
grid.link_escapes(level, cfg);
|
||
grid
|
||
}
|
||
|
||
/// Second linking pass: every cell in a pocket that is NOT the biggest
|
||
/// piece of the map gets its refused neighbours retried with the
|
||
/// generous escape step.
|
||
///
|
||
/// A Doom nukage pool sits 24 map units below its rim, which is exactly
|
||
/// the step limit — and a float baked through the importer lands either
|
||
/// side of it. One pool that reads a hair too tall is a walker who can
|
||
/// never leave the goo. The rule stays Doom's everywhere the map is
|
||
/// already connected; it only relaxes where the alternative is a trap.
|
||
fn link_escapes(&mut self, level: &LevelCollision, cfg: &WalkerConfig) {
|
||
const ORTHO: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];
|
||
// Two rounds, so a pool that escapes into a ledge that was itself
|
||
// trapped ends up connected to the map rather than to another trap.
|
||
for _ in 0..2 {
|
||
let Some(core) = self.best_start() else { return };
|
||
// Which cells can REACH the core. Connectivity here is directed:
|
||
// you fall into a nukage pool through a one-way drop, so the
|
||
// pool is "reachable" from the map while being a prison. Only
|
||
// the reverse flood tells the two apart.
|
||
let out = self.can_reach(core);
|
||
let mut added: Vec<(u32, u32, u32)> = Vec::new();
|
||
for a in 0..self.cells.len() {
|
||
if out[a] {
|
||
continue;
|
||
}
|
||
let (ix, iz) = self.column_xz(self.cells[a].column);
|
||
for (dx, dz) in ORTHO {
|
||
let Some(col) = self.column_index(ix as i32 + dx, iz as i32 + dz) else {
|
||
continue;
|
||
};
|
||
for b in self.column_cells(col) {
|
||
if !out[b] {
|
||
continue; // no use escaping into another prison
|
||
}
|
||
let dy = self.cells[b].pos.y - self.cells[a].pos.y;
|
||
let reach = cfg.step_up + STEP_EPS + hazard_escape_step(cfg.step_up);
|
||
if dy <= cfg.step_up + STEP_EPS || dy > reach {
|
||
continue; // already linked, or genuinely a cliff
|
||
}
|
||
// The knee ray starts just above the step the body
|
||
// is allowed to climb. Probing an ESCAPE step with
|
||
// the ordinary knee height hits the riser of the
|
||
// very step it is asking about, and every way out
|
||
// of the pool reads as a wall.
|
||
if level.path_blocked(
|
||
self.cells[a].pos,
|
||
self.cells[b].pos,
|
||
cfg.radius,
|
||
(cfg.step_up + hazard_escape_step(cfg.step_up)).min(cfg.height * 0.8),
|
||
cfg.height,
|
||
) {
|
||
continue;
|
||
}
|
||
added.push((a as u32, b as u32, NAV_STEP_COST * 3));
|
||
}
|
||
}
|
||
}
|
||
if added.is_empty() {
|
||
return;
|
||
}
|
||
self.refused.escapes += added.len();
|
||
for (a, b, cost) in added {
|
||
self.cells[a as usize].escape = true;
|
||
self.push_edge(a, b, cost);
|
||
}
|
||
self.label_components();
|
||
}
|
||
}
|
||
|
||
/// Cells from which `target` can be walked to — a flood over the
|
||
/// REVERSED edges. The answer to "is this a place you can leave".
|
||
pub fn can_reach(&self, target: u32) -> Vec<bool> {
|
||
let n = self.cells.len();
|
||
let mut rev_head: Vec<u32> = vec![u32::MAX; n];
|
||
let mut rev_next: Vec<u32> = vec![u32::MAX; self.edge_to.len()];
|
||
let mut rev_from: Vec<u32> = vec![0; self.edge_to.len()];
|
||
for a in 0..n {
|
||
let (s, e) = (self.edge_start[a] as usize, self.edge_start[a + 1] as usize);
|
||
for k in s..e {
|
||
let b = self.edge_to[k] as usize;
|
||
rev_from[k] = a as u32;
|
||
rev_next[k] = rev_head[b];
|
||
rev_head[b] = k as u32;
|
||
}
|
||
}
|
||
let mut seen = vec![false; n];
|
||
if (target as usize) < n {
|
||
seen[target as usize] = true;
|
||
}
|
||
let mut stack = vec![target];
|
||
while let Some(b) = stack.pop() {
|
||
let mut k = rev_head[b as usize];
|
||
while k != u32::MAX {
|
||
let a = rev_from[k as usize] as usize;
|
||
if !seen[a] {
|
||
seen[a] = true;
|
||
stack.push(a as u32);
|
||
}
|
||
k = rev_next[k as usize];
|
||
}
|
||
}
|
||
seen
|
||
}
|
||
|
||
/// What the build refused, and why — the numbers that answer "why can
|
||
/// the walker not get out of there".
|
||
pub fn refusals(&self) -> NavRefusals {
|
||
self.refused
|
||
}
|
||
|
||
/// Flood-fill the graph into connected pieces, largest first in
|
||
/// `comp_size`'s value order (ids are assignment order, not rank).
|
||
fn label_components(&mut self) {
|
||
let n = self.cells.len();
|
||
self.component = vec![u32::MAX; n];
|
||
self.comp_size = Vec::new();
|
||
let mut stack: Vec<u32> = Vec::new();
|
||
for seed in 0..n {
|
||
if self.component[seed] != u32::MAX {
|
||
continue;
|
||
}
|
||
let id = self.comp_size.len() as u32;
|
||
let mut size = 0usize;
|
||
self.component[seed] = id;
|
||
stack.push(seed as u32);
|
||
while let Some(a) = stack.pop() {
|
||
size += 1;
|
||
let (s, e) = (
|
||
self.edge_start[a as usize] as usize,
|
||
self.edge_start[a as usize + 1] as usize,
|
||
);
|
||
for k in s..e {
|
||
let b = self.edge_to[k] as usize;
|
||
if self.component[b] == u32::MAX {
|
||
self.component[b] = id;
|
||
stack.push(b as u32);
|
||
}
|
||
}
|
||
}
|
||
self.comp_size.push(size);
|
||
}
|
||
}
|
||
|
||
/// Which connected piece `i` belongs to.
|
||
pub fn component_of(&self, i: u32) -> Option<u32> {
|
||
self.component.get(i as usize).copied().filter(|c| *c != u32::MAX)
|
||
}
|
||
|
||
/// Sizes of every connected piece, biggest first — the honest picture
|
||
/// of how cut up a map is.
|
||
pub fn component_sizes(&self) -> Vec<usize> {
|
||
let mut sizes = self.comp_size.clone();
|
||
sizes.sort_unstable_by(|a, b| b.cmp(a));
|
||
sizes
|
||
}
|
||
|
||
/// Teleporters, as `(pad min, pad max, destination feet, destination
|
||
/// yaw)`. A pad's cells get a ONE-WAY edge to the destination cell, so
|
||
/// the planner routes through a teleporter exactly as Doom's player
|
||
/// does, and the walker cuts to the far side when it steps on one.
|
||
pub fn mark_teleports(&mut self, pads: &[(Vec3f, Vec3f, Vec3f, f32)]) {
|
||
for (min, max, dst, yaw) in pads {
|
||
if self.teleports.len() > u16::MAX as usize {
|
||
break;
|
||
}
|
||
let Some(target) = self.cell_at(*dst) else { continue };
|
||
let id = self.teleports.len() as u16;
|
||
self.teleports.push((*dst, *yaw));
|
||
let pad = self.cell * 0.5;
|
||
let mut sources: Vec<u32> = Vec::new();
|
||
for (i, c) in self.cells.iter_mut().enumerate() {
|
||
if c.pos.x < min.x - pad || c.pos.x > max.x + pad {
|
||
continue;
|
||
}
|
||
if c.pos.z < min.z - pad || c.pos.z > max.z + pad {
|
||
continue;
|
||
}
|
||
if c.pos.y < min.y - 1.0 || c.pos.y > max.y + 1.0 {
|
||
continue;
|
||
}
|
||
c.teleport = Some(id);
|
||
sources.push(i as u32);
|
||
}
|
||
for s in sources {
|
||
self.push_edge(s, target, NAV_STEP_COST);
|
||
}
|
||
}
|
||
self.label_components();
|
||
}
|
||
|
||
/// Append one directed edge after the graph is built. CSR is rebuilt
|
||
/// for the tail only: teleporters are a handful, not a hot path.
|
||
fn push_edge(&mut self, from: u32, to: u32, cost: u32) {
|
||
let at = self.edge_start[from as usize + 1] as usize;
|
||
self.edge_to.insert(at, to);
|
||
self.edge_cost.insert(at, cost);
|
||
for s in self.edge_start.iter_mut().skip(from as usize + 1) {
|
||
*s += 1;
|
||
}
|
||
}
|
||
|
||
/// A teleporter's destination.
|
||
pub fn teleport_target(&self, id: u16) -> Option<(Vec3f, f32)> {
|
||
self.teleports.get(id as usize).copied()
|
||
}
|
||
|
||
/// Orthogonal edges first (one wall probe each), then diagonals, which
|
||
/// are allowed only where BOTH flanking orthogonals are — a body cannot
|
||
/// squeeze through the corner between two walls.
|
||
fn link(&mut self, level: &LevelCollision, cfg: &WalkerConfig) {
|
||
let n = self.cells.len();
|
||
let mut refused = NavRefusals { smallest_refused_rise: f32::MAX, ..Default::default() };
|
||
let mut adj: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n];
|
||
const ORTHO: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];
|
||
const DIAG: [(i32, i32); 4] = [(1, 1), (1, -1), (-1, 1), (-1, -1)];
|
||
for a in 0..n {
|
||
let (ix, iz) = self.column_xz(self.cells[a].column);
|
||
for (dx, dz) in ORTHO {
|
||
let (jx, jz) = (ix as i32 + dx, iz as i32 + dz);
|
||
let Some(col) = self.column_index(jx, jz) else { continue };
|
||
// One wall probe serves every floor in the neighbour column
|
||
// only if they share the walk height, so probe per candidate.
|
||
for b in self.column_cells(col) {
|
||
match self.step_cost(level, cfg, a, b, NAV_STEP_COST) {
|
||
StepVerdict::Ok(cost) => adj[a].push((b as u32, cost)),
|
||
StepVerdict::TooTall(rise) => {
|
||
refused.too_tall += 1;
|
||
refused.smallest_refused_rise =
|
||
refused.smallest_refused_rise.min(rise);
|
||
}
|
||
StepVerdict::TooDeep => refused.too_deep += 1,
|
||
StepVerdict::Walled => refused.walled += 1,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// A diagonal needs its two flanking orthogonal moves to exist, from
|
||
// the SAME cell — that is the standard no-corner-cutting rule.
|
||
for a in 0..n {
|
||
let (ix, iz) = self.column_xz(self.cells[a].column);
|
||
for (dx, dz) in DIAG {
|
||
let Some(col) = self.column_index(ix as i32 + dx, iz as i32 + dz) else { continue };
|
||
let flank_x = self.column_index(ix as i32 + dx, iz as i32);
|
||
let flank_z = self.column_index(ix as i32, iz as i32 + dz);
|
||
let has = |col: Option<usize>| match col {
|
||
Some(c) => adj[a].iter().any(|(b, _)| {
|
||
self.cells[*b as usize].column == c as u32
|
||
}),
|
||
None => false,
|
||
};
|
||
if !has(flank_x) || !has(flank_z) {
|
||
continue;
|
||
}
|
||
for b in self.column_cells(col) {
|
||
if let StepVerdict::Ok(cost) = self.step_cost(level, cfg, a, b, NAV_DIAG_COST) {
|
||
adj[a].push((b as u32, cost));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.edge_start = Vec::with_capacity(n + 1);
|
||
for list in &adj {
|
||
self.edge_start.push(self.edge_to.len() as u32);
|
||
for (b, cost) in list {
|
||
self.edge_to.push(*b);
|
||
self.edge_cost.push(*cost);
|
||
}
|
||
}
|
||
self.edge_start.push(self.edge_to.len() as u32);
|
||
self.refused = refused;
|
||
}
|
||
|
||
/// Cost of stepping a → b, or `None` when the body cannot make it:
|
||
/// a rise over the step-up allowance, a drop past the fall limit, or a
|
||
/// wall between the two centres. A drop between those two is ONE-WAY —
|
||
/// you can walk off a ledge you cannot climb back up.
|
||
fn step_cost(
|
||
&self,
|
||
level: &LevelCollision,
|
||
cfg: &WalkerConfig,
|
||
a: usize,
|
||
b: usize,
|
||
base: u32,
|
||
) -> StepVerdict {
|
||
let (pa, pb) = (self.cells[a].pos, self.cells[b].pos);
|
||
let dy = pb.y - pa.y;
|
||
// Same inclusive step rule the body uses, and the same extra reach
|
||
// out of a hazard: a nukage pit whose rim is exactly one step tall
|
||
// must be a place you can leave.
|
||
let reach = cfg.step_up
|
||
+ STEP_EPS
|
||
+ if self.cells[a].kind == SurfaceKind::Hazard { hazard_escape_step(cfg.step_up) } else { 0.0 };
|
||
if dy > reach {
|
||
return StepVerdict::TooTall(dy);
|
||
}
|
||
if -dy > cfg.fall_limit {
|
||
return StepVerdict::TooDeep;
|
||
}
|
||
if level.path_blocked(pa, pb, cfg.radius, cfg.step_up, cfg.height) {
|
||
return StepVerdict::Walled;
|
||
}
|
||
let hazard = if self.cells[b].kind == SurfaceKind::Hazard { NAV_HAZARD_COST } else { 0 };
|
||
// A drop is walkable but not free: prefer the stairs.
|
||
let fall = if dy < -cfg.step_up { base } else { 0 };
|
||
StepVerdict::Ok(base + hazard + fall)
|
||
}
|
||
|
||
fn column_xz(&self, column: u32) -> (usize, usize) {
|
||
let c = column as usize;
|
||
(c % self.nx, c / self.nx)
|
||
}
|
||
|
||
fn column_index(&self, ix: i32, iz: i32) -> Option<usize> {
|
||
if ix < 0 || iz < 0 || ix as usize >= self.nx || iz as usize >= self.nz {
|
||
return None;
|
||
}
|
||
Some(ix as usize + iz as usize * self.nx)
|
||
}
|
||
|
||
fn column_cells(&self, column: usize) -> std::ops::Range<usize> {
|
||
let (s, e) = (self.col_start[column] as usize, self.col_start[column + 1] as usize);
|
||
s..e
|
||
}
|
||
|
||
pub fn cell_size(&self) -> f32 {
|
||
self.cell
|
||
}
|
||
|
||
pub fn dims(&self) -> (usize, usize) {
|
||
(self.nx, self.nz)
|
||
}
|
||
|
||
/// World x/z of column `(0, 0)`'s CENTRE. A consumer that wants to lay
|
||
/// its own lattice over this one (a strategy map played on a streamed
|
||
/// level) needs the origin as well as the pitch.
|
||
pub fn origin_xz(&self) -> (f32, f32) {
|
||
(self.origin_x, self.origin_z)
|
||
}
|
||
|
||
pub fn len(&self) -> usize {
|
||
self.cells.len()
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.cells.is_empty()
|
||
}
|
||
|
||
pub fn edge_count(&self) -> usize {
|
||
self.edge_to.len()
|
||
}
|
||
|
||
pub fn cell(&self, i: u32) -> Option<&NavCell> {
|
||
self.cells.get(i as usize)
|
||
}
|
||
|
||
/// Every outgoing edge of `i`, as `(neighbour, cost)`.
|
||
pub fn edges(&self, i: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
|
||
let (s, e) = (self.edge_start[i as usize] as usize, self.edge_start[i as usize + 1] as usize);
|
||
(s..e).map(move |k| (self.edge_to[k], self.edge_cost[k]))
|
||
}
|
||
|
||
/// The cell a body at `p` is standing in: same column (or a neighbouring
|
||
/// one, since a cell centre is up to half a cell away), the floor whose
|
||
/// height is nearest the feet.
|
||
pub fn cell_at(&self, p: Vec3f) -> Option<u32> {
|
||
let fx = ((p.x - self.origin_x) / self.cell).round();
|
||
let fz = ((p.z - self.origin_z) / self.cell).round();
|
||
if !fx.is_finite() || !fz.is_finite() {
|
||
return None;
|
||
}
|
||
let (cx, cz) = (fx as i32, fz as i32);
|
||
let mut best: Option<(f32, u32)> = None;
|
||
for dz in -1..=1 {
|
||
for dx in -1..=1 {
|
||
let Some(col) = self.column_index(cx + dx, cz + dz) else { continue };
|
||
for i in self.column_cells(col) {
|
||
let c = &self.cells[i];
|
||
let dy = (c.pos.y - p.y).abs();
|
||
// A floor more than a step out of reach is a different
|
||
// storey, not the one under these feet.
|
||
if dy > 1.0 + self.cell {
|
||
continue;
|
||
}
|
||
let dxz = (c.pos.x - p.x).abs().max((c.pos.z - p.z).abs());
|
||
let score = dy * 4.0 + dxz;
|
||
if best.is_none_or(|(cur, _)| score < cur) {
|
||
best = Some((score, i as u32));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
best.map(|(_, i)| i)
|
||
}
|
||
|
||
/// Mark the cells a door part's footprint covers, by index into `doors`.
|
||
/// Cells under a door are walkable — the walker opens it on approach —
|
||
/// so the door must NOT also be part of the collision mesh.
|
||
pub fn mark_doors(&mut self, doors: &[(Vec3f, Vec3f)]) {
|
||
for (d, (min, max)) in doors.iter().enumerate() {
|
||
if d > u16::MAX as usize {
|
||
break;
|
||
}
|
||
let pad = self.cell * 0.5;
|
||
for c in self.cells.iter_mut() {
|
||
if c.pos.x < min.x - pad || c.pos.x > max.x + pad {
|
||
continue;
|
||
}
|
||
if c.pos.z < min.z - pad || c.pos.z > max.z + pad {
|
||
continue;
|
||
}
|
||
// The door's own storey: its box starts at that floor.
|
||
if c.pos.y < min.y - 1.0 || c.pos.y > max.y + 1.0 {
|
||
continue;
|
||
}
|
||
c.door = Some(d as u16);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Dijkstra from `from` over every edge not in `blocked`, filling
|
||
/// `dist`/`parent` (both resized to the cell count). Returns how many
|
||
/// cells were reached, `from` included.
|
||
pub fn flood(
|
||
&self,
|
||
from: u32,
|
||
blocked: &std::collections::HashSet<u64>,
|
||
dist: &mut Vec<u32>,
|
||
parent: &mut Vec<u32>,
|
||
) -> usize {
|
||
use std::cmp::Reverse;
|
||
use std::collections::BinaryHeap;
|
||
dist.clear();
|
||
dist.resize(self.cells.len(), NAV_UNREACHED);
|
||
parent.clear();
|
||
parent.resize(self.cells.len(), NAV_UNREACHED);
|
||
if from as usize >= self.cells.len() {
|
||
return 0;
|
||
}
|
||
dist[from as usize] = 0;
|
||
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = BinaryHeap::new();
|
||
heap.push(Reverse((0, from)));
|
||
let mut reached = 0;
|
||
while let Some(Reverse((d, a))) = heap.pop() {
|
||
if d > dist[a as usize] {
|
||
continue;
|
||
}
|
||
reached += 1;
|
||
for (b, cost) in self.edges(a) {
|
||
if blocked.contains(&edge_key(a, b)) {
|
||
continue;
|
||
}
|
||
let nd = d + cost;
|
||
if nd < dist[b as usize] {
|
||
dist[b as usize] = nd;
|
||
parent[b as usize] = a;
|
||
heap.push(Reverse((nd, b)));
|
||
}
|
||
}
|
||
}
|
||
reached
|
||
}
|
||
|
||
/// Where to drop the tour: the cell nearest the middle of the LARGEST
|
||
/// connected component, never on hazardous ground.
|
||
///
|
||
/// This replaces the old open-space heuristic outright. That one scored
|
||
/// a 24×24 lattice by how far it could walk in four directions, which
|
||
/// happily picked a wide sealed courtyard the walker could never leave;
|
||
/// "the biggest piece of the map that is actually one piece" cannot.
|
||
pub fn best_start(&self) -> Option<u32> {
|
||
let best_comp = (0..self.comp_size.len() as u32)
|
||
.max_by_key(|c| self.comp_size[*c as usize])?;
|
||
self.middle_of(best_comp)
|
||
}
|
||
|
||
/// The cell nearest the middle of one connected piece, never on
|
||
/// hazardous ground.
|
||
pub fn middle_of(&self, comp: u32) -> Option<u32> {
|
||
let (mut cx, mut cz, mut count) = (0.0f32, 0.0f32, 0.0f32);
|
||
for (i, c) in self.component.iter().enumerate() {
|
||
if *c == comp {
|
||
cx += self.cells[i].pos.x;
|
||
cz += self.cells[i].pos.z;
|
||
count += 1.0;
|
||
}
|
||
}
|
||
if count == 0.0 {
|
||
return None;
|
||
}
|
||
let (cx, cz) = (cx / count, cz / count);
|
||
let mut best: Option<(f32, u32)> = None;
|
||
for (i, c) in self.component.iter().enumerate() {
|
||
if *c != comp || self.cells[i].kind == SurfaceKind::Hazard {
|
||
continue;
|
||
}
|
||
let p = self.cells[i].pos;
|
||
let d = (p.x - cx) * (p.x - cx) + (p.z - cz) * (p.z - cz);
|
||
if best.is_none_or(|(cur, _)| d < cur) {
|
||
best = Some((d, i as u32));
|
||
}
|
||
}
|
||
best.map(|(_, i)| i)
|
||
}
|
||
|
||
/// Somewhere else to continue the tour when the region round the walker
|
||
/// has been seen: the middle of the biggest piece of the map whose cells
|
||
/// are least trodden. Deterministic — `visits` and the sizes decide.
|
||
pub fn next_region(&self, from: u32, visits: &[f32]) -> Option<u32> {
|
||
let here = self.component_of(from);
|
||
let mut best: Option<(f32, usize, u32)> = None;
|
||
for comp in 0..self.comp_size.len() as u32 {
|
||
if Some(comp) == here || self.comp_size[comp as usize] < 4 {
|
||
continue;
|
||
}
|
||
let (mut sum, mut n) = (0.0f32, 0.0f32);
|
||
for (i, c) in self.component.iter().enumerate() {
|
||
if *c == comp {
|
||
sum += visits.get(i).copied().unwrap_or(0.0);
|
||
n += 1.0;
|
||
}
|
||
}
|
||
let mean = if n > 0.0 { sum / n } else { 0.0 };
|
||
let size = self.comp_size[comp as usize];
|
||
// Least trodden first, biggest first among equals.
|
||
let better = match best {
|
||
None => true,
|
||
Some((bm, bs, _)) => mean < bm - 1e-6 || ((mean - bm).abs() <= 1e-6 && size > bs),
|
||
};
|
||
if better {
|
||
best = Some((mean, size, comp));
|
||
}
|
||
}
|
||
best.and_then(|(_, _, comp)| self.middle_of(comp))
|
||
}
|
||
|
||
/// How much of the map is reachable from `i` (a spawn sanity check).
|
||
pub fn reachable_from(&self, i: u32) -> usize {
|
||
let (mut dist, mut parent) = (Vec::new(), Vec::new());
|
||
self.flood(i, &std::collections::HashSet::new(), &mut dist, &mut parent)
|
||
}
|
||
|
||
/// Waypoints from (but excluding) the flood's start to `goal`.
|
||
pub fn path_to(&self, goal: u32, parent: &[u32]) -> Vec<u32> {
|
||
let mut out = Vec::new();
|
||
let mut at = goal;
|
||
// Bounded by the cell count: a parent chain cannot be longer.
|
||
for _ in 0..=self.cells.len() {
|
||
out.push(at);
|
||
let p = *parent.get(at as usize).unwrap_or(&NAV_UNREACHED);
|
||
if p == NAV_UNREACHED {
|
||
break;
|
||
}
|
||
at = p;
|
||
}
|
||
out.reverse();
|
||
// The first entry is the start cell itself.
|
||
if !out.is_empty() {
|
||
out.remove(0);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Pick the next leg: the least-visited cell the tour can reach,
|
||
/// nearest first, with a minimum leg length so it does not spend the
|
||
/// evening shuffling one cell at a time along the frontier.
|
||
///
|
||
/// `jitter` (0..1) breaks ties deterministically from the walker's seed.
|
||
pub fn explore(
|
||
&self,
|
||
from: u32,
|
||
visits: &[f32],
|
||
blocked: &std::collections::HashSet<u64>,
|
||
jitter: f32,
|
||
dist: &mut Vec<u32>,
|
||
parent: &mut Vec<u32>,
|
||
) -> Option<NavPlan> {
|
||
let reachable = self.flood(from, blocked, dist, parent);
|
||
// One visit is worth forty cells of walking: an unseen room across
|
||
// the map beats trodden ground next door, which is the whole point.
|
||
const VISIT_PENALTY: f32 = 400.0;
|
||
/// Shorter legs than this are not worth replanning for.
|
||
const MIN_LEG: u32 = 100;
|
||
let mut best: Option<(f32, u32)> = None;
|
||
let mut best_near: Option<(f32, u32)> = None;
|
||
let mut frontier = f32::MAX;
|
||
for i in 0..self.cells.len() {
|
||
let d = dist[i];
|
||
if d == NAV_UNREACHED || i as u32 == from {
|
||
continue;
|
||
}
|
||
if self.cells[i].kind == SurfaceKind::Hazard {
|
||
continue; // never park the tour in the ooze
|
||
}
|
||
frontier = frontier.min(visits.get(i).copied().unwrap_or(0.0));
|
||
let score = d as f32
|
||
+ visits.get(i).copied().unwrap_or(0.0) * VISIT_PENALTY
|
||
+ jitter * 30.0 * ((i % 7) as f32 / 7.0);
|
||
if best_near.is_none_or(|(cur, _)| score < cur) {
|
||
best_near = Some((score, i as u32));
|
||
}
|
||
if d >= MIN_LEG && best.is_none_or(|(cur, _)| score < cur) {
|
||
best = Some((score, i as u32));
|
||
}
|
||
}
|
||
let (_, goal) = best.or(best_near)?;
|
||
Some(NavPlan {
|
||
goal,
|
||
path: self.path_to(goal, parent),
|
||
reachable,
|
||
frontier: if frontier == f32::MAX { 0.0 } else { frontier },
|
||
})
|
||
}
|
||
}
|
||
|
||
/// What happened to one candidate step of the body.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub enum StepRefusal {
|
||
Ok { rise: f32 },
|
||
/// The knee/chest probe found geometry between here and there.
|
||
Walled,
|
||
/// No floor at all under the destination.
|
||
NoFloor,
|
||
TooTall { rise: f32, reach: f32 },
|
||
Pit { drop: f32 },
|
||
/// The landing is closer than a body radius to a wall.
|
||
NoClearance { rise: f32 },
|
||
}
|
||
|
||
/// Why one neighbour did or did not become an edge.
|
||
enum StepVerdict {
|
||
Ok(u32),
|
||
/// Rise, in world units — the number that says whether the step rule is
|
||
/// off by a float or the wall is genuinely too tall.
|
||
TooTall(f32),
|
||
TooDeep,
|
||
Walled,
|
||
}
|
||
|
||
/// Undirected-safe key for one DIRECTED edge (a→b is invalidated on its own:
|
||
/// a ledge you fell off may still be walkable the other way).
|
||
fn edge_key(a: u32, b: u32) -> u64 {
|
||
(a as u64) << 32 | b as u64
|
||
}
|
||
|
||
/// What the tour is doing right now, for the trace log.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||
pub struct NavStats {
|
||
pub cells: usize,
|
||
pub reachable: usize,
|
||
pub at: Option<u32>,
|
||
pub goal: Option<u32>,
|
||
pub path_left: usize,
|
||
/// Cells stood in at least once since the level loaded.
|
||
pub distinct: usize,
|
||
/// Reachable cells the tour has never stood in (or has forgotten).
|
||
pub unseen: usize,
|
||
/// Visit count of the least-trodden reachable cell.
|
||
pub frontier: f32,
|
||
pub replans: u64,
|
||
pub invalidated: usize,
|
||
/// Teleporter jumps + exhausted-region cuts so far.
|
||
pub cuts: u64,
|
||
}
|
||
|
||
/// Which game's head-bob the camera imitates. Constants are the originals
|
||
/// where the source is public; amplitudes are a fraction of eye height so
|
||
/// they survive each importer's unit conversion.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub enum BobStyle {
|
||
/// Doom `P_CalcHeight` (p_user.c): `bob = (momx² + momy²) >> 2` capped
|
||
/// at `MAXBOB` (16 units), applied as `bob/2 · sin(angle)` with
|
||
/// `angle = FINEANGLES/20 · leveltime` — one cycle per 20 tics, 1.75 Hz
|
||
/// at 35 tics/s. Peak 8 of 41 view-height units.
|
||
#[default]
|
||
Doom,
|
||
/// Build engine (Duke 3D): `bobcounter` advances with horizontal speed
|
||
/// and drives the view offset — quicker and deeper than Doom, with the
|
||
/// engine's lateral sway. Matched by feel; the fixed-point constants
|
||
/// are not transcribed.
|
||
Duke,
|
||
/// Quake `V_CalcBob` (view.c): `cl_bobcycle 0.6`, `cl_bobup 0.5`,
|
||
/// `cl_bob 0.02` × speed, shaped `0.3 + 0.7·sin`, clamped +4/−7 units;
|
||
/// plus `cl_rollangle 2.0` roll as the view swings.
|
||
Quake,
|
||
None,
|
||
}
|
||
|
||
impl BobStyle {
|
||
/// Family from a catalog alias/namespace (`doom/doom/worlds/…`,
|
||
/// `duke/duke3d/…`, `quake/id1/…`). Unknown sources keep Doom's.
|
||
pub fn from_source(text: &str) -> BobStyle {
|
||
let t = text.to_ascii_lowercase();
|
||
if t.contains("duke") || t.contains("build") {
|
||
BobStyle::Duke
|
||
} else if t.contains("quake") || t.contains("q3") || t.contains("q2") {
|
||
BobStyle::Quake
|
||
} else {
|
||
BobStyle::Doom
|
||
}
|
||
}
|
||
|
||
fn frequency(self) -> f32 {
|
||
match self {
|
||
BobStyle::Doom => 35.0 / 20.0,
|
||
BobStyle::Duke => 2.2,
|
||
BobStyle::Quake => 1.0 / 0.6,
|
||
BobStyle::None => 0.0,
|
||
}
|
||
}
|
||
|
||
/// Peak vertical travel as a fraction of eye height.
|
||
///
|
||
/// Doom's MAXBOB/2 is 8 map units — a quarter of a metre, which reads
|
||
/// as seasickness on a big screen at 60 fps rather than as walking.
|
||
/// These are the originals' CADENCE at a comfortable magnitude: about
|
||
/// 3 cm at walk speed for Doom/Build, 2 cm for Quake (`cl_bob 0.02` ×
|
||
/// speed lands there too).
|
||
fn amplitude(self) -> f32 {
|
||
match self {
|
||
BobStyle::Doom => 0.024,
|
||
BobStyle::Duke => 0.030,
|
||
BobStyle::Quake => 0.016,
|
||
BobStyle::None => 0.0,
|
||
}
|
||
}
|
||
|
||
fn sway(self) -> f32 {
|
||
match self {
|
||
BobStyle::Duke => 0.012,
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
fn roll(self) -> f32 {
|
||
match self {
|
||
BobStyle::Quake => 2.0f32.to_radians(),
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
fn wave(self, phase: f32) -> f32 {
|
||
let p = phase.rem_euclid(1.0);
|
||
match self {
|
||
BobStyle::Quake => {
|
||
const BOB_UP: f32 = 0.5;
|
||
let cycle = if p < BOB_UP {
|
||
std::f32::consts::PI * p / BOB_UP
|
||
} else {
|
||
std::f32::consts::PI + std::f32::consts::PI * (p - BOB_UP) / (1.0 - BOB_UP)
|
||
};
|
||
0.3 + 0.7 * cycle.sin()
|
||
}
|
||
BobStyle::None => 0.0,
|
||
_ => (p * std::f32::consts::TAU).sin(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Body + gait, in world units. Defaults are the classic-importer scale
|
||
/// (Doom map units / 64): a 41-unit view height is 0.64, a 24-unit step is
|
||
/// 0.375.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct WalkerConfig {
|
||
pub eye_height: f32,
|
||
/// Body radius — the originals all use 16 map units (0.25 here). The
|
||
/// walker never ends a step with a wall closer than this, which is
|
||
/// also what keeps the camera's near plane out of the geometry.
|
||
pub radius: f32,
|
||
pub height: f32,
|
||
pub speed: f32,
|
||
pub turn_rate: f32,
|
||
/// Ledge the walker steps straight onto, the way the originals do
|
||
/// (Doom 24 map units = 0.375, Quake 18 = 0.28).
|
||
pub step_up: f32,
|
||
/// Deepest drop it will walk off. Beyond this it turns away rather
|
||
/// than diving into a pit; a void (no floor at all) always refuses.
|
||
pub fall_limit: f32,
|
||
/// Downward acceleration while falling, world units/s². Doom's
|
||
/// GRAVITY is 1 map unit per tic² = 19.1 u/s² at 35 tics; Quake's is
|
||
/// 800 Quake units/s² ≈ 12.5 here.
|
||
pub gravity: f32,
|
||
pub probe_ahead: f32,
|
||
pub repick_secs: f32,
|
||
pub bob: BobStyle,
|
||
}
|
||
|
||
impl Default for WalkerConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
eye_height: 0.64,
|
||
radius: 0.25,
|
||
height: 0.85,
|
||
speed: 1.6,
|
||
turn_rate: 1.6,
|
||
step_up: 0.375,
|
||
fall_limit: 2.5,
|
||
gravity: 19.1,
|
||
probe_ahead: 6.0,
|
||
repick_secs: 4.0,
|
||
bob: BobStyle::Doom,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl WalkerConfig {
|
||
/// The body/gait each engine gives its player, at the classic
|
||
/// importer's ORIGINAL metres per map unit (1/64).
|
||
///
|
||
/// These are ratios of an engine's own constants, not the map's units:
|
||
/// an importer is free to publish at whatever scale suits its source
|
||
/// (Quake 1 is 1/32, twice this), and every such map DECLARES its step
|
||
/// height. Use [`Self::with_declared`] to land the body in the units of
|
||
/// the map it is about to walk — a preset alone is a guess.
|
||
pub fn for_style(bob: BobStyle) -> WalkerConfig {
|
||
let base = WalkerConfig { bob, ..WalkerConfig::default() };
|
||
match bob {
|
||
// Doom: radius 16, height 56, view 41, step 24, gravity 1/tic².
|
||
BobStyle::Doom | BobStyle::None => base,
|
||
// Quake: radius 16, height 56, view 22 above the feet of a
|
||
// 56-tall box, step 18, gravity 800 u/s².
|
||
BobStyle::Quake => WalkerConfig {
|
||
eye_height: 0.66,
|
||
step_up: 0.28,
|
||
gravity: 12.5,
|
||
speed: 1.9,
|
||
..base
|
||
},
|
||
// Build (Duke): comparable body, brisker walk.
|
||
BobStyle::Duke => WalkerConfig {
|
||
eye_height: 0.62,
|
||
step_up: 0.34,
|
||
gravity: 16.0,
|
||
speed: 1.8,
|
||
..base
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Put the preset body into the units of the map it is about to walk,
|
||
/// from the facts the importer DECLARED about that map.
|
||
///
|
||
/// Every classic converter publishes its engine's step height and eye
|
||
/// height as anchors, in the GLB's own metres — `step_height` /
|
||
/// `eye_height` (`world_nav.rs`). A preset says "18 units"; the map says
|
||
/// what a unit is worth. Doom's 24 at 1/64 is 0.375 and matches the
|
||
/// preset; Quake 1's 18 at **1/32** is 0.5625, exactly twice the 18-at-
|
||
/// 1/64 the Quake preset carries (Quake II and III publish at 1/64, so
|
||
/// the preset was written for those and silently halved the body on
|
||
/// every Quake 1 map).
|
||
///
|
||
/// A halved body is not a smaller walker, it is a broken one: it cannot
|
||
/// climb the map's own stairs, its nav lattice is finer than its own
|
||
/// width, and the graph it plans on stops being a graph of where it can
|
||
/// go. So the declared step sets the SCALE and the whole body travels
|
||
/// with it — radius, height, eye, step, fall limit, walking speed, and
|
||
/// gravity (length/s², so once as well). Angles and durations do not
|
||
/// scale, and neither does the bob (already a fraction of eye height).
|
||
///
|
||
/// Declaring nothing (Build states no step height) keeps the preset.
|
||
pub fn with_declared(mut self, step_height: Option<f32>, eye_height: Option<f32>) -> Self {
|
||
let scale = step_height
|
||
.filter(|s| s.is_finite() && *s > 1.0e-4)
|
||
.map(|s| s / self.step_up.max(1.0e-4))
|
||
// A map whose step is 30× the preset's is not a scale, it is a
|
||
// bad sidecar: keep the body we know rather than invent one.
|
||
.filter(|k| (0.1..=10.0).contains(k) && (*k - 1.0).abs() > 0.02);
|
||
if let Some(k) = scale {
|
||
self.radius *= k;
|
||
self.height *= k;
|
||
self.step_up *= k;
|
||
self.eye_height *= k;
|
||
self.fall_limit *= k;
|
||
self.speed *= k;
|
||
self.gravity *= k;
|
||
self.probe_ahead *= k;
|
||
}
|
||
// The eye is declared outright, so it needs no scaling guess — and
|
||
// `PlayerNav` already reads the same anchor to turn the authored
|
||
// `player_start` eye into feet. The two must agree or the walker
|
||
// spawns at one height and looks from another.
|
||
if let Some(e) = eye_height.filter(|e| e.is_finite() && *e > 0.05) {
|
||
self.eye_height = e;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// Radius of the circle a body traces when it walks and turns as hard
|
||
/// as it can — walking speed over turn rate.
|
||
pub fn turn_radius(&self) -> f32 {
|
||
self.speed / self.turn_rate.max(0.01)
|
||
}
|
||
}
|
||
|
||
/// Camera pose produced by one walker tick.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct CameraPose {
|
||
pub eye: Vec3f,
|
||
pub yaw: f32,
|
||
pub roll: f32,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum WalkerEvent {
|
||
Stepped,
|
||
/// The way ahead was solid: the walker turned instead of clipping.
|
||
Blocked,
|
||
/// Nothing under the feet at all — a bad start. The host can respawn.
|
||
Stranded,
|
||
}
|
||
|
||
/// Directions tried when picking a new heading (whole turn, evenly spaced).
|
||
const PROBE_DIRS: usize = 12;
|
||
/// How far down a landing spot is looked for while falling.
|
||
const MAX_FALL_SEARCH: f32 = 40.0;
|
||
/// A drop smaller than this is standing on the floor, not falling.
|
||
const LAND_EPS: f32 = 0.02;
|
||
/// Doom's step rule is `step <= 24` map units — INCLUSIVE. At the classic
|
||
/// importer's 1/64 scale that is exactly 0.375, and a sector floor baked
|
||
/// through a float pipeline lands on 0.3750001 as often as on 0.375. A rim
|
||
/// that is exactly one step tall must never read as a wall, or a walker in
|
||
/// the nukage is walled into it.
|
||
const STEP_EPS: f32 = 0.02;
|
||
/// The ONE knee-probe height above a floor, shared by
|
||
/// [`LevelCollision::path_blocked`] (the walk probe — the graph's and the
|
||
/// body's "is that a wall?") and [`LevelCollision::clearance_ok`] (the
|
||
/// landing check — "can the body stand here?"). It sits safely above a
|
||
/// maximal legal step — float noise and the thin riser lip classic
|
||
/// importers leave on step edges included — so a climbable ledge is never
|
||
/// a wall, while anything meaningfully taller than a step still is. For
|
||
/// the Doom body (step 0.375, height 0.85) this is ≈ 0.446.
|
||
///
|
||
/// The two probes MUST use the same height: any gap between them is a
|
||
/// band of wall tops one probe offers and the other refuses forever.
|
||
pub(crate) fn knee_height(step_up: f32, height: f32) -> f32 {
|
||
step_up + (height - step_up) * 0.15
|
||
}
|
||
/// Standing in something that hurts — or in any pocket of floor the normal
|
||
/// step cannot leave — the way OUT is worth a bigger stretch than an
|
||
/// ordinary step: about 38 map units instead of 24. Being stuck in the goo
|
||
/// is worse than a walker who once climbs a step Doom would not have.
|
||
///
|
||
/// A RATIO of the body's own step, not a length: the same 38-vs-24 stretch
|
||
/// has to mean the same thing on a map published at half the metres per map
|
||
/// unit. (`0.375 · 0.6133` is Doom's 0.23 to the last float.)
|
||
const HAZARD_ESCAPE_RATIO: f32 = 0.23 / 0.375;
|
||
|
||
pub(crate) fn hazard_escape_step(step_up: f32) -> f32 {
|
||
step_up * HAZARD_ESCAPE_RATIO
|
||
}
|
||
/// Ticks between forward look-ahead probes (60 Hz → five per second).
|
||
const LOOKAHEAD_TICKS: u32 = 12;
|
||
/// Time constant of the eased turn (a corner takes about this long).
|
||
const TURN_EASE_SECS: f32 = 0.35;
|
||
/// How far the camera's yaw trails the body's heading.
|
||
const CAM_LAG_SECS: f32 = 0.12;
|
||
/// Acceleration/deceleration time.
|
||
const SPEED_EASE_SECS: f32 = 0.30;
|
||
/// Spring rate of the eye's catch-up after a step up (~0.25 s settle).
|
||
const VIEW_CATCHUP_RATE: f32 = 11.0;
|
||
|
||
pub fn yaw_forward(yaw: f32) -> Vec3f {
|
||
// The renderer's camera convention: yaw 0 looks down -Z.
|
||
vec3f(yaw.sin(), 0.0, -yaw.cos())
|
||
}
|
||
|
||
fn wrap_pi(a: f32) -> f32 {
|
||
let tau = std::f32::consts::TAU;
|
||
let mut a = a % tau;
|
||
if a > std::f32::consts::PI {
|
||
a -= tau;
|
||
} else if a < -std::f32::consts::PI {
|
||
a += tau;
|
||
}
|
||
a
|
||
}
|
||
|
||
/// Deterministic xorshift64* — a level tour must repeat exactly.
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct Rng(u64);
|
||
|
||
impl Rng {
|
||
fn new(seed: u64) -> Rng {
|
||
// Mix first: a bare `seed | 1` maps 42 and 43 onto the same state.
|
||
Rng(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ 0xd1b5_4a32_d192_ed03 | 1)
|
||
}
|
||
|
||
fn unit(&mut self) -> f32 {
|
||
let mut x = self.0;
|
||
x ^= x >> 12;
|
||
x ^= x << 25;
|
||
x ^= x >> 27;
|
||
self.0 = x;
|
||
((x.wrapping_mul(0x2545_f491_4f6c_dd1d)) >> 40) as f32 / 16_777_216.0
|
||
}
|
||
}
|
||
|
||
/// An autonomous first-person walker: stays on real floors, slides along
|
||
/// walls, turns away from voids and dead ends, and never ends a tick inside
|
||
/// geometry. Deterministic from its seed.
|
||
pub struct LevelWalker {
|
||
pos: Vec3f,
|
||
yaw: f32,
|
||
target_yaw: f32,
|
||
cfg: WalkerConfig,
|
||
rng: Rng,
|
||
since_pick: f32,
|
||
bob_phase: f32,
|
||
gait: f32,
|
||
turn_rate: f32,
|
||
stuck_ticks: u32,
|
||
/// Falling state: the originals drop you off a ledge instead of
|
||
/// snapping you to the floor below.
|
||
airborne: bool,
|
||
vel_y: f32,
|
||
/// How far the EYE is currently below its nominal height because the
|
||
/// body just stepped up. Doom keeps `viewheight` separate from the
|
||
/// mobj's z for exactly this: the body snaps onto the tread, the view
|
||
/// catches up over ~0.25 s, and stairs read as a smooth rise instead
|
||
/// of a series of jumps.
|
||
view_offset: f32,
|
||
view_vel: f32,
|
||
/// Eased walking speed, so starts, stops and corners do not snap.
|
||
speed_now: f32,
|
||
/// Camera yaw, lagging the body's heading slightly.
|
||
cam_yaw: f32,
|
||
probe_countdown: u32,
|
||
/// Distance to the point the route is steering at, when there is one.
|
||
/// The tick's speed limit reads it: a body may not walk faster than its
|
||
/// own turn can steer toward what it is aiming at.
|
||
aim_dist: Option<f32>,
|
||
/// Stand still this tick (waiting for a door to open).
|
||
hold: bool,
|
||
/// The cell under the feet is in a pocket the graph had to link with
|
||
/// the escape step: the body gets the same reach while it stands there.
|
||
escape_step: bool,
|
||
// ---- player_nav seam ------------------------------------------------
|
||
/// An outside planner owns the route: the built-in least-visited tour
|
||
/// stops choosing goals, and everything BELOW the heading — eased turn,
|
||
/// step, gravity, wall-slide, bob, door wait, teleport, relocate/flash —
|
||
/// keeps working exactly as it does for the built-in tour.
|
||
external: bool,
|
||
/// Route handed in before the first tick (the nav state does not exist
|
||
/// until a grid has been seen).
|
||
ext_route: Option<Vec<u32>>,
|
||
/// Heading to hold when the route is empty — a look-around pan.
|
||
ext_yaw: Option<f32>,
|
||
/// Door the planner wants opened, when it drives that itself.
|
||
ext_door: Option<u16>,
|
||
// player_nav: stand in place while the route is empty (a look-around
|
||
// pan turns the body without walking it into a wall).
|
||
ext_hold: bool,
|
||
// player_nav: walk-speed scale the planner sets for the slight
|
||
// slow-down at doors and glances. 1.0 = the config's full speed.
|
||
ext_speed: f32,
|
||
/// Exploration state, created the first time a nav grid is handed in.
|
||
nav: Option<NavState>,
|
||
}
|
||
|
||
/// Everything the tour remembers about where it has been. Kept in the
|
||
/// walker (not the grid) so two walkers can explore one shared map.
|
||
struct NavState {
|
||
/// Per-cell visit count, decayed slowly so a map is eventually re-toured.
|
||
visits: Vec<f32>,
|
||
/// Cells stood in and not yet forgotten. Unlike `visits` this does not
|
||
/// decay every tick, so "have I seen everything I can reach" is a
|
||
/// question with a stable answer; a cell whose visit count has faded
|
||
/// away is forgotten with it.
|
||
seen: Vec<bool>,
|
||
/// Reachable cells never stood in, as of the last plan.
|
||
unseen: usize,
|
||
dist: Vec<u32>,
|
||
parent: Vec<u32>,
|
||
/// Directed edges the walker proved it cannot actually make.
|
||
blocked: std::collections::HashSet<u64>,
|
||
/// Remaining waypoints, in order.
|
||
path: std::collections::VecDeque<u32>,
|
||
goal: Option<u32>,
|
||
at: Option<u32>,
|
||
reachable: usize,
|
||
distinct: usize,
|
||
replans: u64,
|
||
since_plan: f32,
|
||
since_decay: f32,
|
||
/// Seconds since the walker last made real ground toward its waypoint.
|
||
since_progress: f32,
|
||
/// Closest the body has come to the current waypoint. Displacement on
|
||
/// its own is not progress: a walker jiggling along a wall covers
|
||
/// metres a second and arrives nowhere.
|
||
best_gap: f32,
|
||
/// Ticks spent unable to move at all.
|
||
blocked_ticks: u32,
|
||
/// Which way it is currently trying to slide past an obstruction.
|
||
slide: f32,
|
||
/// Door the host should open, and how long we have waited for it.
|
||
want_door: Option<u16>,
|
||
door_wait: f32,
|
||
open_doors: Vec<u16>,
|
||
/// Goals handed out this session, newest last (bounded, for the trace).
|
||
goal_log: Vec<u32>,
|
||
/// Least-visited reachable cell as of the last plan.
|
||
frontier: f32,
|
||
/// The host owes the picture a cut-flash (Doom's teleport white-out).
|
||
flash: bool,
|
||
cuts: u64,
|
||
since_cut: f32,
|
||
/// Unbroken seconds spent standing in something that hurts.
|
||
hazard_secs: f32,
|
||
}
|
||
|
||
/// No progress for this long means the plan is a lie: drop the edge and
|
||
/// re-plan rather than grinding into a wall the probe said was open.
|
||
const NAV_STUCK_SECS: f32 = 3.0;
|
||
/// Ground covered under which "no progress" is declared.
|
||
const NAV_PROGRESS_EPS: f32 = 0.25;
|
||
/// A leg is re-planned at least this often, so a changed world (a door that
|
||
/// opened, an edge that was invalidated) is noticed.
|
||
const NAV_REPLAN_SECS: f32 = 12.0;
|
||
/// Visit counts fade by this factor every `NAV_DECAY_SECS`.
|
||
const NAV_DECAY: f32 = 0.6;
|
||
const NAV_DECAY_SECS: f32 = 30.0;
|
||
/// How long the tour waits at a closed door before walking on regardless.
|
||
const NAV_DOOR_WAIT: f32 = 1.5;
|
||
/// How far ahead the tour looks for a door to open.
|
||
const NAV_DOOR_LOOKAHEAD: usize = 3;
|
||
/// Newest goals kept for the trace log.
|
||
const NAV_GOAL_LOG: usize = 64;
|
||
/// A tour that has not moved for this long is not going to: cut elsewhere.
|
||
/// (A classic map still bakes its doors into the static mesh, so a route
|
||
/// can genuinely end at a wall that Doom would have opened.)
|
||
const NAV_CUT_AFTER_SECS: f32 = 10.0;
|
||
/// Minimum gap between cuts, so a bad corner cannot strobe the picture.
|
||
const NAV_CUT_COOLDOWN: f32 = 6.0;
|
||
/// Longest the tour may stand in a hazard before it is cut out of it.
|
||
const NAV_HAZARD_ESCAPE_SECS: f32 = 6.0;
|
||
|
||
impl NavState {
|
||
fn new(cells: usize) -> NavState {
|
||
NavState {
|
||
visits: vec![0.0; cells],
|
||
seen: vec![false; cells],
|
||
unseen: cells,
|
||
dist: Vec::new(),
|
||
parent: Vec::new(),
|
||
blocked: std::collections::HashSet::new(),
|
||
path: std::collections::VecDeque::new(),
|
||
goal: None,
|
||
at: None,
|
||
reachable: 0,
|
||
distinct: 0,
|
||
replans: 0,
|
||
since_plan: f32::MAX,
|
||
since_decay: 0.0,
|
||
since_progress: 0.0,
|
||
best_gap: f32::MAX,
|
||
blocked_ticks: 0,
|
||
slide: 0.0,
|
||
want_door: None,
|
||
door_wait: 0.0,
|
||
open_doors: Vec::new(),
|
||
goal_log: Vec::new(),
|
||
frontier: 0.0,
|
||
flash: false,
|
||
cuts: 0,
|
||
since_cut: NAV_CUT_COOLDOWN,
|
||
hazard_secs: 0.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl LevelWalker {
|
||
pub fn new(start: Vec3f, yaw: f32, cfg: WalkerConfig, seed: u64) -> LevelWalker {
|
||
LevelWalker {
|
||
pos: start,
|
||
yaw,
|
||
target_yaw: yaw,
|
||
cfg,
|
||
rng: Rng::new(seed),
|
||
since_pick: 0.0,
|
||
bob_phase: 0.0,
|
||
gait: 0.0,
|
||
turn_rate: 0.0,
|
||
stuck_ticks: 0,
|
||
airborne: false,
|
||
vel_y: 0.0,
|
||
view_offset: 0.0,
|
||
view_vel: 0.0,
|
||
speed_now: 0.0,
|
||
cam_yaw: yaw,
|
||
probe_countdown: 0,
|
||
aim_dist: None,
|
||
hold: false,
|
||
escape_step: false,
|
||
external: false,
|
||
ext_route: None,
|
||
ext_yaw: None,
|
||
ext_door: None,
|
||
ext_hold: false,
|
||
ext_speed: 1.0,
|
||
nav: None,
|
||
}
|
||
}
|
||
|
||
/// The door the tour wants opened before it walks on, if any. The host
|
||
/// answers with `Renderer::set_model_state(level, "door_N", "open", …)`
|
||
/// and then calls [`Self::set_door_open`].
|
||
pub fn wanted_door(&self) -> Option<u16> {
|
||
self.nav.as_ref().and_then(|n| n.want_door)
|
||
}
|
||
|
||
/// Tell the walker a door part is now passable (or shut again).
|
||
pub fn set_door_open(&mut self, door: u16, open: bool) {
|
||
let Some(nav) = self.nav.as_mut() else { return };
|
||
let held = nav.open_doors.iter().position(|d| *d == door);
|
||
match (open, held) {
|
||
(true, None) => nav.open_doors.push(door),
|
||
(false, Some(i)) => {
|
||
nav.open_doors.remove(i);
|
||
}
|
||
_ => {}
|
||
}
|
||
if open && nav.want_door == Some(door) {
|
||
nav.want_door = None;
|
||
nav.door_wait = 0.0;
|
||
}
|
||
}
|
||
|
||
/// What the exploration is doing — the numbers `VJ_WALKER_TRACE` logs.
|
||
pub fn nav_stats(&self) -> NavStats {
|
||
match &self.nav {
|
||
Some(n) => NavStats {
|
||
cells: n.visits.len(),
|
||
reachable: n.reachable,
|
||
at: n.at,
|
||
goal: n.goal,
|
||
path_left: n.path.len(),
|
||
distinct: n.distinct,
|
||
unseen: n.unseen,
|
||
frontier: n.frontier,
|
||
replans: n.replans,
|
||
invalidated: n.blocked.len(),
|
||
cuts: n.cuts,
|
||
},
|
||
None => NavStats::default(),
|
||
}
|
||
}
|
||
|
||
/// The goals handed out so far, newest last (bounded).
|
||
pub fn nav_goal_log(&self) -> &[u32] {
|
||
self.nav.as_ref().map(|n| n.goal_log.as_slice()).unwrap_or(&[])
|
||
}
|
||
|
||
// ---- player_nav seam ----------------------------------------------------
|
||
// A planner that thinks in ROOMS lives outside this module and drives the
|
||
// body through these four calls. Note when routing: a DIAGONAL step out of
|
||
// a rim corner is refused by the landing clearance check (`StepRefusal::
|
||
// NoClearance`) even where the grid offers the edge — orthogonal exits out
|
||
// of a sunken area are the reliable ones, so prefer them.
|
||
|
||
/// Hand the route over. While this is on, the built-in least-visited
|
||
/// tour stops picking goals and cutting to other regions; locomotion,
|
||
/// waypoint following, the stuck watchdog, doors, teleporters and the
|
||
/// cut flash all keep working.
|
||
pub fn set_external_planner(&mut self, on: bool) {
|
||
self.external = on;
|
||
}
|
||
|
||
pub fn has_external_planner(&self) -> bool {
|
||
self.external
|
||
}
|
||
|
||
/// Replace the waypoint queue with `route` (nav-grid cell ids, in order).
|
||
/// Taken verbatim: the walker consumes waypoints as it reaches them and
|
||
/// stops steering when the queue empties.
|
||
pub fn set_route(&mut self, route: Vec<u32>) {
|
||
match self.nav.as_mut() {
|
||
Some(nav) => {
|
||
nav.path = route.into();
|
||
nav.goal = nav.path.back().copied();
|
||
nav.best_gap = f32::MAX;
|
||
nav.since_progress = 0.0;
|
||
nav.blocked_ticks = 0;
|
||
// A fresh route is a fresh plan, so the "try the other way
|
||
// round the obstruction" offset goes with the old one. The
|
||
// built-in tour cleared it on its own replan; an EXTERNAL
|
||
// planner never runs that branch, so once a watchdog set
|
||
// the offset the body steered 34° off every bearing it was
|
||
// given, for the rest of the level. That crab is a body
|
||
// that cannot converge on any waypoint — it circles one
|
||
// forever, which is the tour that looks demented.
|
||
nav.slide = 0.0;
|
||
}
|
||
None => self.ext_route = Some(route),
|
||
}
|
||
}
|
||
|
||
/// What is left of the route.
|
||
pub fn route(&self) -> Vec<u32> {
|
||
self.nav.as_ref().map(|n| n.path.iter().copied().collect()).unwrap_or_default()
|
||
}
|
||
|
||
/// Hold this heading while the route is empty (a look-around pan).
|
||
/// `None` lets the body keep the heading it has.
|
||
pub fn set_target_yaw(&mut self, yaw: Option<f32>) {
|
||
self.ext_yaw = yaw;
|
||
if let Some(y) = yaw {
|
||
if self.nav.as_ref().is_none_or(|n| n.path.is_empty()) {
|
||
self.target_yaw = wrap_pi(y);
|
||
}
|
||
}
|
||
}
|
||
|
||
// player_nav: stand still while the route is empty — the look-around
|
||
// pan turns the body in place instead of walking it forward. Has no
|
||
// effect while a route is being followed (a door wait still holds).
|
||
pub fn set_hold(&mut self, hold: bool) {
|
||
self.ext_hold = hold;
|
||
}
|
||
|
||
// player_nav: walk-speed scale (the slight slow-down through doorways
|
||
// and during glances); clamped to 0..1, 1 = the config's full speed.
|
||
pub fn set_speed_scale(&mut self, scale: f32) {
|
||
self.ext_speed = scale.clamp(0.0, 1.0);
|
||
}
|
||
|
||
/// Ask the host to open a door (what `wanted_door` will report until
|
||
/// `set_door_open` clears it). The built-in look-ahead does this by
|
||
/// itself for the built-in tour.
|
||
pub fn request_door(&mut self, door: Option<u16>) {
|
||
self.ext_door = door;
|
||
if let Some(nav) = self.nav.as_mut() {
|
||
nav.want_door = door.filter(|d| !nav.open_doors.contains(d));
|
||
}
|
||
}
|
||
|
||
/// Put the body somewhere else instantly — a teleporter, or a cut to
|
||
/// another wing of a map the tour cannot walk to. Physics and the eye
|
||
/// spring are reset so the arrival is a CUT, not a fall.
|
||
pub fn relocate(&mut self, feet: Vec3f, yaw: f32) {
|
||
self.pos = feet;
|
||
self.yaw = wrap_pi(yaw);
|
||
self.target_yaw = self.yaw;
|
||
self.cam_yaw = self.yaw;
|
||
self.vel_y = 0.0;
|
||
self.airborne = false;
|
||
self.view_offset = 0.0;
|
||
self.view_vel = 0.0;
|
||
self.speed_now = 0.0;
|
||
self.gait = 0.0;
|
||
self.stuck_ticks = 0;
|
||
self.hold = false;
|
||
if let Some(n) = self.nav.as_mut() {
|
||
n.path.clear();
|
||
n.goal = None;
|
||
n.at = None;
|
||
n.since_plan = f32::MAX;
|
||
n.since_progress = 0.0;
|
||
n.best_gap = f32::MAX;
|
||
n.blocked_ticks = 0;
|
||
n.slide = 0.0;
|
||
n.since_cut = 0.0;
|
||
n.cuts += 1;
|
||
n.flash = true;
|
||
}
|
||
}
|
||
|
||
/// True once per cut: the host paints Doom's white teleport flash.
|
||
pub fn take_flash(&mut self) -> bool {
|
||
match self.nav.as_mut() {
|
||
Some(n) => std::mem::replace(&mut n.flash, false),
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
/// How many cuts (teleporters + exhausted-region jumps) so far.
|
||
pub fn cut_count(&self) -> u64 {
|
||
self.nav.as_ref().map(|n| n.cuts).unwrap_or(0)
|
||
}
|
||
|
||
/// True while the walker is falling (no floor under its feet).
|
||
pub fn is_airborne(&self) -> bool {
|
||
self.airborne
|
||
}
|
||
|
||
pub fn feet(&self) -> Vec3f {
|
||
self.pos
|
||
}
|
||
|
||
pub fn yaw(&self) -> f32 {
|
||
self.yaw
|
||
}
|
||
|
||
pub fn config(&self) -> &WalkerConfig {
|
||
&self.cfg
|
||
}
|
||
|
||
/// Camera for this tick: eye height above the feet plus the engine's
|
||
/// head bob (speed-proportional, zero standing still), and its roll.
|
||
pub fn camera(&self) -> CameraPose {
|
||
let cfg = &self.cfg;
|
||
// Doom's bob is momentum-SQUARED, so it fades away quickly as the
|
||
// walker slows and is zero at rest.
|
||
let effort = self.gait * self.gait;
|
||
let lift = cfg.bob.wave(self.bob_phase) * cfg.bob.amplitude() * cfg.eye_height * effort;
|
||
let sway = cfg.bob.sway()
|
||
* cfg.eye_height
|
||
* effort
|
||
* (self.bob_phase * std::f32::consts::PI).sin();
|
||
let right = vec3f(self.cam_yaw.cos(), 0.0, self.cam_yaw.sin());
|
||
CameraPose {
|
||
eye: vec3f(
|
||
self.pos.x + right.x * sway,
|
||
self.pos.y + cfg.eye_height + lift - self.view_offset,
|
||
self.pos.z + right.z * sway,
|
||
),
|
||
yaw: self.cam_yaw,
|
||
roll: (self.turn_rate / cfg.turn_rate.max(0.01)).clamp(-1.0, 1.0) * cfg.bob.roll(),
|
||
}
|
||
}
|
||
|
||
/// One fixed step against the level's triangles, wandering by local
|
||
/// probes alone. Use [`Self::tick_in`] to tour a whole map.
|
||
pub fn tick(&mut self, dt: f32, level: &LevelCollision) -> WalkerEvent {
|
||
self.tick_in(dt, level, None)
|
||
}
|
||
|
||
/// One fixed step. With a [`NavGrid`] the heading comes from a planned
|
||
/// route through the map's least-visited ground; without one it falls
|
||
/// back to scoring the twelve directions from where it stands.
|
||
pub fn tick_in(
|
||
&mut self,
|
||
dt: f32,
|
||
level: &LevelCollision,
|
||
nav: Option<&NavGrid>,
|
||
) -> WalkerEvent {
|
||
let dt = dt.clamp(0.0, 0.1);
|
||
let nav = nav.filter(|g| !g.is_empty());
|
||
if let Some(grid) = nav {
|
||
self.nav_steer(dt, grid);
|
||
}
|
||
self.since_pick += dt;
|
||
self.probe_countdown = self.probe_countdown.saturating_sub(1);
|
||
// Everything that raises the BODY within this tick is instant (a
|
||
// step onto a tread, or the ground snap when a slope rises under
|
||
// the feet). The eye must absorb all of it, so the reference is
|
||
// taken here, before either can happen.
|
||
let body_before = self.pos.y;
|
||
// Eased turn: the rate ramps down as the heading is reached, so a
|
||
// corner is a curve rather than a snap. Still rate-limited by the
|
||
// engine's turn speed (~90°/s).
|
||
let delta = wrap_pi(self.target_yaw - self.yaw);
|
||
let rate = (delta / TURN_EASE_SECS).clamp(-self.cfg.turn_rate, self.cfg.turn_rate);
|
||
let turn = (rate * dt).clamp(-delta.abs(), delta.abs());
|
||
self.yaw = wrap_pi(self.yaw + turn);
|
||
self.turn_rate = if dt > 0.0 { turn / dt } else { 0.0 };
|
||
// The camera trails the body's heading a little.
|
||
let cam_delta = wrap_pi(self.yaw - self.cam_yaw);
|
||
self.cam_yaw = wrap_pi(self.cam_yaw + cam_delta * (dt / CAM_LAG_SECS).min(1.0));
|
||
let facing_target = delta.abs() < 0.6;
|
||
|
||
// Where the ground is right now. A walker with nothing at all
|
||
// beneath it does not move: better a stopped tour than a camera
|
||
// drifting through the void.
|
||
let ground = self.ground_below(self.pos, level);
|
||
let Some(ground) = ground else {
|
||
self.stuck_ticks += 1;
|
||
return WalkerEvent::Stranded;
|
||
};
|
||
|
||
// Falling: the originals do not snap you down a ledge, they drop
|
||
// you (Doom GRAVITY, Quake sv_gravity). Horizontal motion carries
|
||
// on mid-air, and the landing dips the view like `deltaviewheight`.
|
||
if self.airborne || self.pos.y - ground.y > LAND_EPS {
|
||
self.airborne = true;
|
||
self.vel_y -= self.cfg.gravity * dt;
|
||
self.pos.y += self.vel_y * dt;
|
||
if self.pos.y <= ground.y {
|
||
self.pos.y = ground.y;
|
||
// Doom's landing sets `deltaviewheight` — a VELOCITY, not
|
||
// a height: the view sinks over the next few tics and
|
||
// springs back. Adding the dip to the offset directly is
|
||
// what makes a landing snap.
|
||
self.view_vel += (-self.vel_y * 0.35).min(1.6);
|
||
self.vel_y = 0.0;
|
||
self.airborne = false;
|
||
}
|
||
} else {
|
||
self.pos.y = ground.y;
|
||
self.vel_y = 0.0;
|
||
}
|
||
|
||
// Accelerate/decelerate over ~0.3 s instead of stepping at full
|
||
// speed from a standstill.
|
||
// player_nav: `ext_speed` is 1.0 unless an external planner asks
|
||
// for the slight door/glance slow-down, so the built-in tours are
|
||
// unchanged by the multiply.
|
||
let want_speed = match (self.hold, facing_target) {
|
||
(true, _) => 0.0,
|
||
(false, true) => self.cfg.speed * self.ext_speed,
|
||
(false, false) => self.cfg.speed * 0.25 * self.ext_speed,
|
||
};
|
||
// A body walking `v` and turning at `w` traces a circle of radius
|
||
// `v / w`. If what it is steering at lies INSIDE that circle it can
|
||
// never turn toward it — it orbits, at full turn rate, until
|
||
// something else rescues it. So the turn sets the speed: never walk
|
||
// faster than `turn_rate · distance to the aim point`. That is the
|
||
// slow-into-the-corner a person does, and it is a HARD requirement,
|
||
// not a comfort: without it the tour circles a waypoint forever
|
||
// wherever the route bends tighter than the body can turn. Doom's
|
||
// 1 m circle sits well inside its string-pulled waypoints and never
|
||
// meets the cap; a map published at twice the metres per map unit
|
||
// walks twice as fast and meets it at every corner.
|
||
let want_speed = match self.aim_dist {
|
||
Some(d) => want_speed.min(self.cfg.turn_rate * d),
|
||
None => want_speed,
|
||
};
|
||
self.speed_now += (want_speed - self.speed_now) * (dt / SPEED_EASE_SECS).min(1.0);
|
||
let step = self.speed_now * dt;
|
||
let dir = yaw_forward(self.yaw);
|
||
let mut moved = false;
|
||
// The body walks THROUGH its turns, at the quarter speed decided
|
||
// just above. It used to stand still for every heading change wider
|
||
// than `facing_target` — and that dead stop was the whole jitter:
|
||
// an eight-neighbour route turns 45° at a corner, 45° is wider than
|
||
// the gate, so the walker stopped, spun, took a step, stopped again.
|
||
// Worse, it was self-feeding: standing still is no progress, no
|
||
// progress trips the planner's watchdog, a dropped leg makes the
|
||
// next one CELL-BY-CELL, and a cell-by-cell leg turns at every
|
||
// single cell. Stepping while turning is safe on its own — every
|
||
// candidate step still goes through `try_move`, which refuses to
|
||
// enter geometry — and a person walks the curve rather than pausing
|
||
// to aim.
|
||
for delta in [
|
||
vec3f(dir.x * step, 0.0, dir.z * step),
|
||
vec3f(dir.x * step, 0.0, 0.0),
|
||
vec3f(0.0, 0.0, dir.z * step),
|
||
] {
|
||
if delta.x == 0.0 && delta.z == 0.0 {
|
||
continue;
|
||
}
|
||
if let Some(landed) = self.try_move(delta, level) {
|
||
self.pos.x = landed.x;
|
||
self.pos.z = landed.z;
|
||
// Step UP (or level) instantly, as the originals do.
|
||
// A lower floor is NOT snapped to: the walker is now
|
||
// unsupported and next tick's gravity drops it.
|
||
if !self.airborne && landed.y >= self.pos.y - LAND_EPS {
|
||
self.pos.y = landed.y;
|
||
}
|
||
moved = true;
|
||
break;
|
||
}
|
||
}
|
||
// A body step UP is instant and collision-correct; the EYE is not
|
||
// moved with it — `view_offset` holds it back and the spring below
|
||
// eases it up (Doom's `deltaviewheight`).
|
||
let climbed = self.pos.y - body_before;
|
||
if climbed > LAND_EPS {
|
||
self.view_offset = (self.view_offset + climbed).min(self.cfg.eye_height * 0.8);
|
||
}
|
||
// Critically damped catch-up, ~0.25 s.
|
||
let w = VIEW_CATCHUP_RATE;
|
||
self.view_vel += (-w * w * self.view_offset - 2.0 * w * self.view_vel) * dt;
|
||
self.view_offset += self.view_vel * dt;
|
||
|
||
let target_gait = if moved && !self.airborne {
|
||
(self.speed_now / self.cfg.speed.max(0.01)).clamp(0.0, 1.0)
|
||
} else {
|
||
0.0
|
||
};
|
||
self.gait += (target_gait - self.gait) * (dt * 4.0).min(1.0);
|
||
self.bob_phase = (self.bob_phase + self.cfg.bob.frequency() * self.gait * dt).fract();
|
||
if moved {
|
||
self.stuck_ticks = 0;
|
||
if let Some(n) = self.nav.as_mut() {
|
||
n.blocked_ticks = 0;
|
||
} else {
|
||
// Turn EARLY: a heading with less than two body widths of
|
||
// floor left is already a dead end as far as the camera is
|
||
// concerned.
|
||
if self.probe_countdown == 0 {
|
||
self.probe_countdown = LOOKAHEAD_TICKS;
|
||
if level.free_run(self.pos, self.yaw, &self.cfg) < self.cfg.radius * 2.0 {
|
||
self.pick_heading(level);
|
||
}
|
||
}
|
||
if self.since_pick >= self.cfg.repick_secs {
|
||
self.pick_heading(level);
|
||
}
|
||
}
|
||
return WalkerEvent::Stepped;
|
||
}
|
||
if facing_target && !self.airborne {
|
||
self.stuck_ticks += 1;
|
||
// With a route, a refused step is evidence about ONE edge — the
|
||
// planner deals with it (see `nav_steer`). Re-scoring headings
|
||
// here would fight the plan.
|
||
match self.nav.as_mut() {
|
||
Some(n) => n.blocked_ticks += 1,
|
||
None => self.pick_heading(level),
|
||
}
|
||
if self.stuck_ticks > 240 {
|
||
return WalkerEvent::Stranded;
|
||
}
|
||
return WalkerEvent::Blocked;
|
||
}
|
||
WalkerEvent::Stepped
|
||
}
|
||
|
||
/// Route the tour: keep track of where it is, plan a new leg when the
|
||
/// old one is done (or has been proven wrong), and aim the heading at
|
||
/// the next waypoint. Everything else — turning, stepping, gravity,
|
||
/// wall-slide — is the ordinary tick.
|
||
fn nav_steer(&mut self, dt: f32, grid: &NavGrid) {
|
||
if self.nav.as_ref().is_none_or(|n| n.visits.len() != grid.len()) {
|
||
self.nav = Some(NavState::new(grid.len()));
|
||
// A route handed in before the first grid was seen.
|
||
if let Some(route) = self.ext_route.take() {
|
||
self.set_route(route);
|
||
}
|
||
}
|
||
let external = self.external;
|
||
let feet = self.pos;
|
||
let cell_size = grid.cell_size();
|
||
// Drawn every tick, not only when a plan happens: the borrow of
|
||
// `self.nav` below covers the whole routine, and a tie-break that
|
||
// advances on a fixed schedule is still deterministic.
|
||
let jitter = self.rng.unit();
|
||
let nav = self.nav.as_mut().expect("just created");
|
||
nav.since_plan += dt;
|
||
nav.since_decay += dt;
|
||
nav.since_progress += dt;
|
||
nav.since_cut += dt;
|
||
// Visit counts fade, so a map toured for ten minutes is toured
|
||
// again rather than settling into one loop through the leftovers.
|
||
if nav.since_decay >= NAV_DECAY_SECS {
|
||
nav.since_decay = 0.0;
|
||
for v in nav.visits.iter_mut() {
|
||
*v *= NAV_DECAY;
|
||
}
|
||
}
|
||
// Where are we? A body between storeys (mid-fall) may be nowhere.
|
||
let here = grid.cell_at(feet);
|
||
let in_hazard = here
|
||
.and_then(|c| grid.cell(c))
|
||
.is_some_and(|c| c.kind == SurfaceKind::Hazard);
|
||
self.escape_step = here.and_then(|c| grid.cell(c)).is_some_and(|c| c.escape);
|
||
nav.hazard_secs = if in_hazard { nav.hazard_secs + dt } else { 0.0 };
|
||
if let Some(c) = here {
|
||
if nav.at != Some(c) {
|
||
nav.at = Some(c);
|
||
let v = &mut nav.visits[c as usize];
|
||
if *v <= 0.0 {
|
||
nav.distinct += 1;
|
||
}
|
||
*v += 1.0;
|
||
nav.seen[c as usize] = true;
|
||
// Reaching a new cell IS progress, however slowly.
|
||
nav.since_progress = 0.0;
|
||
nav.best_gap = f32::MAX;
|
||
nav.blocked_ticks = 0;
|
||
}
|
||
}
|
||
// Consume waypoints already walked through. Matching the CELL alone
|
||
// is not enough: a body that clips the corner of a waypoint without
|
||
// landing in it leaves that waypoint in the queue, and the next aim
|
||
// turns it round to go back for it — the ping-pong the plan exists
|
||
// to prevent.
|
||
while let Some(w) = nav.path.front().copied() {
|
||
let reached = Some(w) == here
|
||
|| grid.cell(w).is_some_and(|c| {
|
||
let (dx, dz) = (c.pos.x - feet.x, c.pos.z - feet.z);
|
||
(dx * dx + dz * dz).sqrt() < cell_size * 0.5
|
||
&& (c.pos.y - feet.y).abs() < self.cfg.step_up + 0.1
|
||
});
|
||
if !reached {
|
||
break;
|
||
}
|
||
// A waypoint walked PAST counts as walked THROUGH: without this
|
||
// the cell stays "unseen", is picked as a goal again, is passed
|
||
// again, and the tour circles one spot forever.
|
||
let w = w as usize;
|
||
if !nav.seen[w] {
|
||
nav.seen[w] = true;
|
||
if nav.visits[w] <= 0.0 {
|
||
nav.distinct += 1;
|
||
}
|
||
nav.visits[w] += 1.0;
|
||
}
|
||
nav.path.pop_front();
|
||
nav.best_gap = f32::MAX;
|
||
}
|
||
// Progress is measured against the WAYPOINT, not against the last
|
||
// position: a body sliding back and forth along a wall covers metres
|
||
// a second and gets no nearer to anything.
|
||
match nav.path.front().and_then(|w| grid.cell(*w)) {
|
||
Some(c) => {
|
||
let (dx, dz) = (c.pos.x - feet.x, c.pos.z - feet.z);
|
||
let gap = (dx * dx + dz * dz).sqrt();
|
||
if gap + NAV_PROGRESS_EPS < nav.best_gap {
|
||
nav.best_gap = gap;
|
||
nav.since_progress = 0.0;
|
||
}
|
||
}
|
||
None => nav.since_progress = 0.0,
|
||
}
|
||
let give_up = nav.since_progress >= NAV_STUCK_SECS || nav.blocked_ticks > 40;
|
||
if give_up {
|
||
// The edge we were trying to take does not exist in practice.
|
||
if let (Some(a), Some(b)) = (nav.at, nav.path.front().copied()) {
|
||
nav.blocked.insert(edge_key(a, b));
|
||
}
|
||
nav.path.clear();
|
||
nav.goal = None;
|
||
nav.since_progress = 0.0;
|
||
nav.blocked_ticks = 0;
|
||
// Slide the other way next time this happens.
|
||
nav.slide = if nav.slide >= 0.0 { -0.6 } else { 0.6 };
|
||
}
|
||
let need_plan = !external && (nav.path.is_empty() || nav.since_plan >= NAV_REPLAN_SECS);
|
||
if need_plan {
|
||
nav.since_plan = 0.0;
|
||
let start = here.or(nav.at);
|
||
if let Some(start) = start {
|
||
let (mut dist, mut parent) = (
|
||
std::mem::take(&mut nav.dist),
|
||
std::mem::take(&mut nav.parent),
|
||
);
|
||
let plan = grid.explore(
|
||
start,
|
||
&nav.visits,
|
||
&nav.blocked,
|
||
jitter,
|
||
&mut dist,
|
||
&mut parent,
|
||
);
|
||
nav.dist = dist;
|
||
nav.parent = parent;
|
||
match plan {
|
||
Some(plan) => {
|
||
nav.reachable = plan.reachable;
|
||
nav.frontier = plan.frontier;
|
||
// How much of what it can reach it has not seen —
|
||
// the honest "is there anything left here" test,
|
||
// and one the visit decay cannot blur.
|
||
nav.unseen = (0..nav.seen.len())
|
||
.filter(|i| {
|
||
nav.dist.get(*i).copied().unwrap_or(NAV_UNREACHED) != NAV_UNREACHED
|
||
&& !nav.seen[*i]
|
||
&& grid.cell(*i as u32).is_some_and(|c| {
|
||
c.kind != SurfaceKind::Hazard
|
||
})
|
||
})
|
||
.count();
|
||
nav.goal = Some(plan.goal);
|
||
nav.path = plan.path.into();
|
||
nav.replans += 1;
|
||
nav.goal_log.push(plan.goal);
|
||
if nav.goal_log.len() > NAV_GOAL_LOG {
|
||
nav.goal_log.remove(0);
|
||
}
|
||
nav.slide = 0.0;
|
||
}
|
||
None => {
|
||
// Nothing reachable at all: forget the invalidated
|
||
// edges rather than paralysing the tour forever.
|
||
nav.blocked.clear();
|
||
nav.goal = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Stepping onto a teleport pad IS the edge: cut to the far side the
|
||
// way Doom does, white flash and all.
|
||
let pad = here.and_then(|c| grid.cell(c)).and_then(|c| c.teleport);
|
||
// Nothing new within reach (or a route that has gone nowhere for ten
|
||
// seconds — a classic map still bakes its doors into the walls):
|
||
// cut to another wing rather than pacing a room it has memorised.
|
||
// Paddling in the nukage for this long means the way out is not
|
||
// one the graph can see: cut, rather than stand in it dying.
|
||
// With an outside planner, WHERE to go next is not this module's
|
||
// business — only a hazard it is drowning in still forces a cut.
|
||
let exhausted = match external {
|
||
true => nav.hazard_secs >= NAV_HAZARD_ESCAPE_SECS,
|
||
false => {
|
||
nav.unseen == 0
|
||
|| nav.since_progress >= NAV_CUT_AFTER_SECS
|
||
|| nav.hazard_secs >= NAV_HAZARD_ESCAPE_SECS
|
||
}
|
||
};
|
||
let elsewhere = (exhausted && nav.since_cut >= NAV_CUT_COOLDOWN)
|
||
.then(|| nav.at.and_then(|a| grid.next_region(a, &nav.visits)))
|
||
.flatten();
|
||
if let Some(dst) = pad.and_then(|t| grid.teleport_target(t)) {
|
||
self.relocate(dst.0, dst.1);
|
||
return;
|
||
}
|
||
if let Some(cell) = elsewhere.and_then(|c| grid.cell(c)) {
|
||
// Forget the region being left behind, so coming back to it in
|
||
// ten minutes is a tour again and not an instant bounce out.
|
||
for i in 0..nav.seen.len() {
|
||
if nav.dist.get(i).copied().unwrap_or(NAV_UNREACHED) != NAV_UNREACHED {
|
||
nav.seen[i] = false;
|
||
}
|
||
}
|
||
let (pos, yaw) = (cell.pos, self.yaw);
|
||
self.relocate(pos, yaw);
|
||
return;
|
||
}
|
||
// A door on the near stretch of the route is opened before we get
|
||
// there; the tour pauses briefly if it is still shut.
|
||
let ext_door = self.ext_door;
|
||
let nav = self.nav.as_mut().expect("still there");
|
||
nav.want_door = match external {
|
||
// The planner says which door; the built-in tour finds it itself.
|
||
true => ext_door.filter(|d| !nav.open_doors.contains(d)),
|
||
false => nav
|
||
.path
|
||
.iter()
|
||
.take(NAV_DOOR_LOOKAHEAD)
|
||
.filter_map(|w| grid.cell(*w).and_then(|c| c.door))
|
||
.find(|d| !nav.open_doors.contains(d)),
|
||
};
|
||
let waiting = match nav.want_door {
|
||
Some(_) => {
|
||
nav.door_wait += dt;
|
||
nav.door_wait < NAV_DOOR_WAIT
|
||
}
|
||
None => {
|
||
nav.door_wait = 0.0;
|
||
false
|
||
}
|
||
};
|
||
// Aim at the next waypoint. Waypoints are cell centres, so the
|
||
// heading is simply the bearing to the next one, plus the slide
|
||
// offset that walks the body along a wall it snagged on.
|
||
//
|
||
// The aim NEVER jumps a waypoint the queue still holds: a route is
|
||
// a proven chain of cells and the straight line to a later one may
|
||
// go through a wall. What keeps a close waypoint reachable is the
|
||
// speed cap below, not skipping it.
|
||
let mut aim = None;
|
||
for w in nav.path.iter() {
|
||
let Some(c) = grid.cell(*w) else { continue };
|
||
let (dx, dz) = (c.pos.x - feet.x, c.pos.z - feet.z);
|
||
if (dx * dx + dz * dz).sqrt() < cell_size * 0.35 {
|
||
continue; // standing on it already
|
||
}
|
||
aim = Some((dx, dz));
|
||
break;
|
||
}
|
||
// How far the thing being steered at is — the tick's speed limit.
|
||
// A body walking `v` while turning at `w` traces a circle of radius
|
||
// `v/w`; steering at anything INSIDE that circle is a circle round
|
||
// it, never a path to it, and the body orbits at full turn rate
|
||
// until something else rescues it. That orbit is the tour a viewer
|
||
// calls demented, and it is what a corner does to a walker whose
|
||
// route bends tighter than its own turning circle.
|
||
self.aim_dist = aim.map(|(dx, dz)| (dx * dx + dz * dz).sqrt());
|
||
let slide = nav.slide;
|
||
match aim {
|
||
// yaw 0 looks down -Z (see `yaw_forward`).
|
||
Some((dx, dz)) => self.target_yaw = wrap_pi(dx.atan2(-dz) + slide),
|
||
// Route done: hold whatever heading the planner asked for.
|
||
None => {
|
||
if let Some(y) = self.ext_yaw {
|
||
self.target_yaw = wrap_pi(y);
|
||
}
|
||
}
|
||
}
|
||
// A closed door stops the tour where it stands rather than letting
|
||
// it grind into the leaf. player_nav: an external planner may also
|
||
// hold the body for its look-around pan (route empty, turning only).
|
||
self.hold = waiting
|
||
|| (external && self.ext_hold && self.nav.as_ref().is_none_or(|n| n.path.is_empty()));
|
||
}
|
||
|
||
/// The floor under `p`: a step up is allowed, and the search reaches
|
||
/// far enough down to find the landing spot of a fall.
|
||
fn ground_below(&self, p: Vec3f, level: &LevelCollision) -> Option<FloorHit> {
|
||
self.ground_below_up(p, level, 0.0)
|
||
}
|
||
|
||
/// The probe has to START above the tallest tread it may find, or a
|
||
/// ledge exactly `step_up` up is missed by the ray that was supposed to
|
||
/// find it — which is how a rim of exactly 24 map units became a wall.
|
||
fn ground_below_up(&self, p: Vec3f, level: &LevelCollision, extra: f32) -> Option<FloorHit> {
|
||
let up = self.cfg.step_up + STEP_EPS + extra;
|
||
let from = vec3f(p.x, p.y + up, p.z);
|
||
let reach = up + self.cfg.fall_limit.max(MAX_FALL_SEARCH);
|
||
let hit = level.floor_below(from, reach)?;
|
||
(hit.flatness >= 0.5).then_some(hit)
|
||
}
|
||
|
||
/// Why one candidate step was refused — the diagnostic behind
|
||
/// "it will not climb that ledge".
|
||
pub fn step_refusal(&self, level: &LevelCollision, delta: Vec3f) -> StepRefusal {
|
||
let want = vec3f(self.pos.x + delta.x, self.pos.y, self.pos.z + delta.z);
|
||
let standing_in = self
|
||
.ground_below(self.pos, level)
|
||
.map(|f| f.kind)
|
||
.unwrap_or_default();
|
||
let escape = match standing_in == SurfaceKind::Hazard || self.escape_step {
|
||
true => hazard_escape_step(self.cfg.step_up),
|
||
false => 0.0,
|
||
};
|
||
let step = (self.cfg.step_up + escape).min(self.cfg.height * 0.8);
|
||
if level.path_blocked(self.pos, want, self.cfg.radius, step, self.cfg.height) {
|
||
return StepRefusal::Walled;
|
||
}
|
||
let Some(floor) = self.ground_below_up(want, level, escape) else {
|
||
return StepRefusal::NoFloor;
|
||
};
|
||
let rise = floor.y - self.pos.y;
|
||
let reach = self.cfg.step_up + STEP_EPS + escape;
|
||
if rise > reach {
|
||
return StepRefusal::TooTall { rise, reach };
|
||
}
|
||
if !self.airborne && -rise > self.cfg.fall_limit {
|
||
return StepRefusal::Pit { drop: -rise };
|
||
}
|
||
// The landing check gets the SAME step allowance as the walk probe
|
||
// (escape stretch included): the two must never disagree about a
|
||
// ledge, or the body is offered a step it refuses forever.
|
||
let stand = vec3f(want.x, floor.y.max(self.pos.y), want.z);
|
||
if !level.clearance_ok(stand, self.cfg.radius, step, self.cfg.height) {
|
||
return StepRefusal::NoClearance { rise };
|
||
}
|
||
StepRefusal::Ok { rise }
|
||
}
|
||
|
||
/// Apply a horizontal step if the body fits, the walls stay a body
|
||
/// radius away, and there is floor within the step/drop budget.
|
||
///
|
||
/// A step UP happens instantly, the way the originals do it. A step
|
||
/// DOWN does NOT snap: the walker simply becomes unsupported and
|
||
/// gravity drops it, so ledges are fallen off rather than teleported
|
||
/// down. A drop deeper than `fall_limit` is refused outright — that is
|
||
/// a pit, and the tour turns away from it.
|
||
fn try_move(&self, delta: Vec3f, level: &LevelCollision) -> Option<Vec3f> {
|
||
let want = vec3f(self.pos.x + delta.x, self.pos.y, self.pos.z + delta.z);
|
||
// Standing in the ooze — or anywhere the graph had to link with the
|
||
// generous escape step — the rim is always climbable.
|
||
let standing_in = self
|
||
.ground_below(self.pos, level)
|
||
.map(|f| f.kind)
|
||
.unwrap_or_default();
|
||
let escape = match standing_in == SurfaceKind::Hazard || self.escape_step {
|
||
true => hazard_escape_step(self.cfg.step_up),
|
||
false => 0.0,
|
||
};
|
||
// The knee ray must start above the tallest step this body may take,
|
||
// or climbing out of a pool is refused by the riser of the very step
|
||
// being asked about.
|
||
let step = (self.cfg.step_up + escape).min(self.cfg.height * 0.8);
|
||
if level.path_blocked(self.pos, want, self.cfg.radius, step, self.cfg.height) {
|
||
return None;
|
||
}
|
||
let floor = self.ground_below_up(want, level, escape)?;
|
||
let reach = self.cfg.step_up + STEP_EPS + escape;
|
||
if floor.y - self.pos.y > reach {
|
||
return None; // too tall to step onto
|
||
}
|
||
if !self.airborne && self.pos.y - floor.y > self.cfg.fall_limit {
|
||
return None; // a pit, not a step
|
||
}
|
||
// Keep the camera's near plane clear of the walls: the body never
|
||
// ends a step with geometry inside its radius. Same step allowance
|
||
// as the walk probe above — the two must never disagree.
|
||
let stand = vec3f(want.x, floor.y.max(self.pos.y), want.z);
|
||
if !level.clearance_ok(stand, self.cfg.radius, step, self.cfg.height) {
|
||
return None;
|
||
}
|
||
Some(vec3f(want.x, floor.y, want.z))
|
||
}
|
||
|
||
/// Turn toward the most open direction; ties and a small preference
|
||
/// jitter come from the seed, so the wander repeats exactly.
|
||
fn pick_heading(&mut self, level: &LevelCollision) {
|
||
self.since_pick = 0.0;
|
||
let mut best = (f32::MIN, self.yaw);
|
||
for i in 0..PROBE_DIRS {
|
||
let yaw = wrap_pi(self.yaw + std::f32::consts::TAU * i as f32 / PROBE_DIRS as f32);
|
||
let open = level.free_run(self.pos, yaw, &self.cfg);
|
||
let ahead = 1.0 - wrap_pi(yaw - self.yaw).abs() / std::f32::consts::PI;
|
||
// Nukage, lava and slime are floors the tour would rather not
|
||
// stand in: a hazardous run scores far below a clean one, so
|
||
// the bridge wins whenever there is a bridge.
|
||
let hazard = if level.has_kinds() {
|
||
level.hazard_run(self.pos, yaw, &self.cfg)
|
||
} else {
|
||
0.0
|
||
};
|
||
// The forward bias has to outweigh ordinary differences in
|
||
// open distance, or the walker ping-pongs along a corridor:
|
||
// the way it just came is always the longest free run.
|
||
let score = open - hazard * 4.0 + ahead * 2.5 + self.rng.unit() * 0.5;
|
||
if score > best.0 {
|
||
best = (score, yaw);
|
||
}
|
||
}
|
||
self.target_yaw = best.1;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Append a quad (two triangles) to a triangle soup.
|
||
fn quad(pos: &mut Vec<Vec3f>, idx: &mut Vec<u32>, a: Vec3f, b: Vec3f, c: Vec3f, d: Vec3f) {
|
||
let n = pos.len() as u32;
|
||
pos.extend_from_slice(&[a, b, c, d]);
|
||
idx.extend_from_slice(&[n, n + 1, n + 2, n, n + 2, n + 3]);
|
||
}
|
||
|
||
/// A horizontal slab top at `y` spanning the given x/z range.
|
||
fn floor(pos: &mut Vec<Vec3f>, idx: &mut Vec<u32>, x0: f32, x1: f32, z0: f32, z1: f32, y: f32) {
|
||
quad(
|
||
pos,
|
||
idx,
|
||
vec3f(x0, y, z0),
|
||
vec3f(x1, y, z0),
|
||
vec3f(x1, y, z1),
|
||
vec3f(x0, y, z1),
|
||
);
|
||
}
|
||
|
||
/// A vertical wall spanning x0..x1 at `z`, from y0 to y1.
|
||
fn wall_z(pos: &mut Vec<Vec3f>, idx: &mut Vec<u32>, x0: f32, x1: f32, z: f32, y0: f32, y1: f32) {
|
||
quad(
|
||
pos,
|
||
idx,
|
||
vec3f(x0, y0, z),
|
||
vec3f(x1, y0, z),
|
||
vec3f(x1, y1, z),
|
||
vec3f(x0, y1, z),
|
||
);
|
||
}
|
||
|
||
/// A vertical wall spanning z0..z1 at `x`, from y0 to y1.
|
||
fn wall_x(pos: &mut Vec<Vec3f>, idx: &mut Vec<u32>, z0: f32, z1: f32, x: f32, y0: f32, y1: f32) {
|
||
quad(
|
||
pos,
|
||
idx,
|
||
vec3f(x, y0, z0),
|
||
vec3f(x, y0, z1),
|
||
vec3f(x, y1, z1),
|
||
vec3f(x, y1, z0),
|
||
);
|
||
}
|
||
|
||
/// A roofed corridor along -Z: floor at 0, ceiling at 2, side walls,
|
||
/// and an end wall at z = -10. Open end at z = +1.
|
||
fn corridor() -> LevelCollision {
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
floor(&mut p, &mut i, -1.0, 1.0, -10.0, 1.0, 0.0);
|
||
floor(&mut p, &mut i, -1.0, 1.0, -10.0, 1.0, 2.0); // ceiling
|
||
wall_x(&mut p, &mut i, -10.0, 1.0, -1.0, 0.0, 2.0);
|
||
wall_x(&mut p, &mut i, -10.0, 1.0, 1.0, 0.0, 2.0);
|
||
wall_z(&mut p, &mut i, -1.0, 1.0, -10.0, 0.0, 2.0);
|
||
LevelCollision::from_positions(p, i)
|
||
}
|
||
|
||
fn cfg() -> WalkerConfig {
|
||
WalkerConfig {
|
||
radius: 0.2,
|
||
height: 0.8,
|
||
eye_height: 0.6,
|
||
speed: 1.5,
|
||
turn_rate: 6.0,
|
||
step_up: 0.3,
|
||
probe_ahead: 8.0,
|
||
..WalkerConfig::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn floor_probe_finds_the_real_triangle_under_the_feet() {
|
||
let level = corridor();
|
||
assert_eq!(level.triangles(), 10);
|
||
let hit = level.floor_below(vec3f(0.0, 1.0, 0.0), 4.0).unwrap();
|
||
assert!((hit.y - 0.0).abs() < 1e-4, "stands on the floor slab: {hit:?}");
|
||
assert!(hit.flatness > 0.99, "a floor is flat: {hit:?}");
|
||
// The ceiling is found looking up, which is what proves "indoors".
|
||
assert_eq!(level.ceiling_above(vec3f(0.0, 0.1, 0.0), 8.0), Some(2.0));
|
||
// Outside the corridor there is no floor at all.
|
||
assert!(level.floor_below(vec3f(5.0, 1.0, 0.0), 4.0).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn walls_block_and_open_space_does_not() {
|
||
let level = corridor();
|
||
let cfg = cfg();
|
||
let from = vec3f(0.0, 0.0, 0.0);
|
||
assert!(
|
||
!level.path_blocked(from, vec3f(0.0, 0.0, -1.0), cfg.radius, cfg.step_up, cfg.height),
|
||
"the corridor is open ahead"
|
||
);
|
||
assert!(
|
||
level.path_blocked(from, vec3f(2.0, 0.0, 0.0), cfg.radius, cfg.step_up, cfg.height),
|
||
"the side wall is solid"
|
||
);
|
||
// Free run stops at the end wall, not before and not through it.
|
||
let run = level.free_run(from, 0.0, &cfg);
|
||
assert!(run > 8.0 && run < 10.5, "ran {run} down a 10-unit corridor");
|
||
}
|
||
|
||
#[test]
|
||
fn walker_stays_on_the_floor_and_never_enters_a_wall() {
|
||
let level = corridor();
|
||
let cfg = WalkerConfig { repick_secs: 1.0e6, ..cfg() };
|
||
let start = level.interior_start(&cfg).expect("a corridor is walkable");
|
||
assert!((start.y - 0.0).abs() < 1e-3, "starts on the floor: {start:?}");
|
||
let mut w = LevelWalker::new(start, 0.0, cfg, 7);
|
||
let mut turned_back = false;
|
||
let mut min_z = f32::MAX;
|
||
for _ in 0..1800 {
|
||
let ev = w.tick(1.0 / 60.0, &level);
|
||
assert_ne!(ev, WalkerEvent::Stranded, "a corridor is not a void");
|
||
let feet = w.feet();
|
||
// ON the floor — the mid-air bug this module exists to kill.
|
||
assert!((feet.y - 0.0).abs() < 1e-3, "left the floor: {feet:?}");
|
||
// The body radius is kept clear of every wall, which is also
|
||
// what keeps the camera's near plane out of the geometry.
|
||
let clear = cfg.radius - 1e-3;
|
||
assert!(
|
||
feet.x > -1.0 + clear && feet.x < 1.0 - clear,
|
||
"camera too close to a side wall: {feet:?}"
|
||
);
|
||
assert!(feet.z > -10.0 + clear, "too close to the end wall: {feet:?}");
|
||
min_z = min_z.min(feet.z);
|
||
// It turns AWAY before it ever touches the dead end.
|
||
turned_back |= w.yaw().abs() > 2.0;
|
||
}
|
||
assert!(min_z < -5.0, "should get down the corridor, min_z={min_z}");
|
||
assert!(turned_back, "the dead end turns the walker around");
|
||
}
|
||
|
||
#[test]
|
||
fn climbs_a_staircase_and_stands_on_each_tread() {
|
||
let cfg = cfg();
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
// Four 0.25-high treads (each within step_up 0.3) going -Z.
|
||
floor(&mut p, &mut i, -1.0, 1.0, -1.0, 2.0, 0.0);
|
||
for n in 0..4 {
|
||
let y = 0.25 * (n + 1) as f32;
|
||
let z0 = -1.0 - n as f32;
|
||
floor(&mut p, &mut i, -1.0, 1.0, z0 - 1.0, z0, y);
|
||
}
|
||
floor(&mut p, &mut i, -1.0, 1.0, -8.0, 2.0, 4.0); // ceiling
|
||
wall_x(&mut p, &mut i, -8.0, 2.0, -1.0, 0.0, 4.0);
|
||
wall_x(&mut p, &mut i, -8.0, 2.0, 1.0, 0.0, 4.0);
|
||
let level = LevelCollision::from_positions(p, i);
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 1.5), 0.0, WalkerConfig { repick_secs: 1.0e6, ..cfg }, 3);
|
||
let mut top = 0.0f32;
|
||
for _ in 0..900 {
|
||
w.tick(1.0 / 60.0, &level);
|
||
let feet = w.feet();
|
||
top = top.max(feet.y);
|
||
// Always exactly on a tread, never between them.
|
||
// Grounded, the feet are exactly on a tread; mid-fall (coming
|
||
// back DOWN the stairs) they are legitimately between them.
|
||
if !w.is_airborne() {
|
||
let tread = (feet.y / 0.25).round() * 0.25;
|
||
assert!((feet.y - tread).abs() < 1e-3, "not on a tread: {feet:?}");
|
||
}
|
||
}
|
||
assert!(top >= 0.75, "climbed the stairs, reached {top}");
|
||
}
|
||
|
||
#[test]
|
||
fn stairs_rise_smoothly_even_though_the_body_snaps() {
|
||
// Five treads. The BODY steps instantly (Doom sets z), the EYE
|
||
// must not: no visible jump bigger than a couple of centimetres.
|
||
let cfg = WalkerConfig { repick_secs: 1.0e6, ..cfg() };
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
floor(&mut p, &mut i, -1.0, 1.0, -1.0, 2.0, 0.0);
|
||
for n in 0..5 {
|
||
let y = 0.25 * (n + 1) as f32;
|
||
let z0 = -1.0 - n as f32;
|
||
floor(&mut p, &mut i, -1.0, 1.0, z0 - 1.0, z0, y);
|
||
}
|
||
floor(&mut p, &mut i, -1.0, 1.0, -9.0, 2.0, 6.0);
|
||
wall_x(&mut p, &mut i, -9.0, 2.0, -1.0, 0.0, 6.0);
|
||
wall_x(&mut p, &mut i, -9.0, 2.0, 1.0, 0.0, 6.0);
|
||
let level = LevelCollision::from_positions(p, i);
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 1.5), 0.0, cfg, 3);
|
||
let mut prev_eye = w.camera().eye.y;
|
||
let (mut body_jumps, mut worst_grounded, mut worst_any) = (0, 0.0f32, 0.0f32);
|
||
let mut prev_body = w.feet().y;
|
||
// The HIGHEST tread reached, not where the wander happened to stop:
|
||
// the walker walks through its turns, so the tail of a free wander
|
||
// is wherever it was going next, and the subject here is the eye.
|
||
let mut top = w.feet().y;
|
||
for _ in 0..900 {
|
||
w.tick(1.0 / 60.0, &level);
|
||
let body = w.feet().y;
|
||
top = top.max(body);
|
||
let eye = w.camera().eye.y;
|
||
if body - prev_body > 0.1 {
|
||
body_jumps += 1; // the body DOES snap up a tread
|
||
}
|
||
let jump = (eye - prev_eye).abs();
|
||
worst_any = worst_any.max(jump);
|
||
// While the feet are on the ground — climbing or walking — the
|
||
// eye may only ease. Falling back DOWN the stairs it follows
|
||
// the body at gravity's pace, which is not a snap.
|
||
if !w.is_airborne() {
|
||
worst_grounded = worst_grounded.max(jump);
|
||
}
|
||
prev_body = body;
|
||
prev_eye = eye;
|
||
}
|
||
assert!(body_jumps >= 3, "the body climbed treads: {body_jumps}");
|
||
// ~0.3 s to absorb a 0.25-unit tread is about 3 cm of eye travel
|
||
// per tick at 60 Hz — smooth, and the same catch-up Doom gives
|
||
// `viewheight`. The BODY's 0.25 jump never reaches the camera.
|
||
assert!(
|
||
worst_grounded < 0.03,
|
||
"climbing must ease the view, not snap: worst {worst_grounded} per tick"
|
||
);
|
||
// Even falling, the eye never moves faster than free fall over one
|
||
// tread (√(2·g·0.25) ≈ 3.1 u/s ≈ 0.052 per tick).
|
||
assert!(worst_any < 0.06, "eye outran gravity: {worst_any} per tick");
|
||
assert!(top >= 0.75, "climbed the stairs, reached {top}");
|
||
}
|
||
|
||
#[test]
|
||
fn head_bob_stays_within_a_few_centimetres() {
|
||
// "Sickeningly big" was 25 cm — Doom's raw MAXBOB/2 in map units.
|
||
// At the importer's scale one world unit is about two metres, so
|
||
// the peak has to stay near 0.015 units.
|
||
let level = corridor();
|
||
for style in [BobStyle::Doom, BobStyle::Duke, BobStyle::Quake] {
|
||
let cfg = WalkerConfig { bob: style, repick_secs: 1.0e6, ..cfg() };
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 0.5), 0.0, cfg, 3);
|
||
let mut peak = 0.0f32;
|
||
for i in 0..600 {
|
||
w.tick(1.0 / 60.0, &level);
|
||
if i > 120 {
|
||
peak = peak.max((w.camera().eye.y - w.feet().y - cfg.eye_height).abs());
|
||
}
|
||
}
|
||
assert!(peak < 0.025, "{style:?} bob peak {peak} world units (~5 cm cap)");
|
||
assert!(peak > 0.002, "{style:?} bob should still be visible: {peak}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn falls_down_a_drop_instead_of_teleporting() {
|
||
// A 1.2-unit drop: too deep to step down, inside fall_limit, so
|
||
// the originals would let you walk off and fall.
|
||
let cfg = WalkerConfig { repick_secs: 1.0e6, fall_limit: 2.0, ..cfg() };
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
floor(&mut p, &mut i, -1.0, 1.0, -2.0, 2.0, 1.2); // upper ledge
|
||
floor(&mut p, &mut i, -1.0, 1.0, -8.0, -2.0, 0.0); // lower floor
|
||
floor(&mut p, &mut i, -1.0, 1.0, -8.0, 2.0, 5.0); // ceiling
|
||
wall_x(&mut p, &mut i, -8.0, 2.0, -1.0, 0.0, 5.0);
|
||
wall_x(&mut p, &mut i, -8.0, 2.0, 1.0, 0.0, 5.0);
|
||
let level = LevelCollision::from_positions(p, i);
|
||
let mut w = LevelWalker::new(vec3f(0.0, 1.2, 1.5), 0.0, cfg, 3);
|
||
let mut heights = Vec::new();
|
||
let mut airborne_ticks = 0;
|
||
// Eye above feet on the tick the fall ENDS: that is the landing dip
|
||
// the test is about. Reading it 600 ticks later measures head bob,
|
||
// which rises above the nominal eye height by design.
|
||
let mut landing_dip = None;
|
||
let mut was_airborne = false;
|
||
for _ in 0..600 {
|
||
w.tick(1.0 / 60.0, &level);
|
||
heights.push(w.feet().y);
|
||
if w.is_airborne() {
|
||
airborne_ticks += 1;
|
||
} else if was_airborne && landing_dip.is_none() {
|
||
landing_dip = Some(w.camera().eye.y - w.feet().y);
|
||
}
|
||
was_airborne = w.is_airborne();
|
||
}
|
||
let landed = *heights.last().unwrap();
|
||
assert!((landed - 0.0).abs() < 1e-3, "lands on the lower floor: {landed}");
|
||
assert!(airborne_ticks > 4, "it FELL, over several ticks: {airborne_ticks}");
|
||
// Never teleports: no single tick moves more than gravity allows.
|
||
for pair in heights.windows(2) {
|
||
let drop = pair[0] - pair[1];
|
||
assert!(drop < 0.35, "teleported down {drop} in one tick");
|
||
}
|
||
// Landing dips the view, then eases back (Doom's deltaviewheight).
|
||
let dip = landing_dip.expect("the walker landed");
|
||
assert!(dip <= cfg.eye_height + 1e-3, "the view never rises on landing: {dip}");
|
||
assert!(dip > 0.0, "the eye is still above the feet: {dip}");
|
||
}
|
||
|
||
#[test]
|
||
fn walks_the_bridge_instead_of_the_nukage() {
|
||
// Two safe platforms joined by a hazard strip AND a clean bridge.
|
||
// Every triangle's kind is declared, so the tour can choose.
|
||
let cfg = WalkerConfig { repick_secs: 2.0, ..cfg() };
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
let mut kinds = Vec::new();
|
||
let push = |p: &mut Vec<Vec3f>,
|
||
i: &mut Vec<u32>,
|
||
kinds: &mut Vec<SurfaceKind>,
|
||
x0: f32,
|
||
x1: f32,
|
||
z0: f32,
|
||
z1: f32,
|
||
y: f32,
|
||
kind: SurfaceKind| {
|
||
floor(p, i, x0, x1, z0, z1, y);
|
||
kinds.push(kind);
|
||
kinds.push(kind);
|
||
};
|
||
// Start platform (z 0..2) and far platform (z -6..-4), joined by
|
||
// TWO corridors: nukage on the left (x -3..-1), a clean bridge on
|
||
// the right (x 1..3). The gap between them has no floor at all, so
|
||
// the tour must commit to one or the other.
|
||
push(&mut p, &mut i, &mut kinds, -3.0, 3.0, 0.0, 2.0, 0.0, SurfaceKind::Floor);
|
||
push(&mut p, &mut i, &mut kinds, -3.0, 3.0, -6.0, -4.0, 0.0, SurfaceKind::Floor);
|
||
push(&mut p, &mut i, &mut kinds, -3.0, -1.0, -4.0, 0.0, 0.0, SurfaceKind::Hazard);
|
||
push(&mut p, &mut i, &mut kinds, 1.0, 3.0, -4.0, 0.0, 0.0, SurfaceKind::Floor);
|
||
// Ceiling (nobody stands on it, but it makes the room interior).
|
||
push(&mut p, &mut i, &mut kinds, -3.0, 3.0, -6.0, 2.0, 4.0, SurfaceKind::Floor);
|
||
let level = LevelCollision::from_positions(p, i).with_kinds(kinds);
|
||
assert!(level.has_kinds(), "the fixture declares its surfaces");
|
||
// The probe agrees which corridor is which.
|
||
assert!(level.hazard_run(vec3f(-2.0, 0.0, -0.5), 0.0, &cfg) > 0.5, "left is ooze");
|
||
assert_eq!(level.hazard_run(vec3f(2.0, 0.0, -0.5), 0.0, &cfg), 0.0, "right is clean");
|
||
// Started in the middle of the start platform, facing the gap.
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 1.0), 0.0, cfg, 4);
|
||
let (mut ooze_ticks, mut bridge_ticks, mut reached_far) = (0, 0, false);
|
||
for _ in 0..5400 {
|
||
w.tick(1.0 / 60.0, &level);
|
||
let feet = w.feet();
|
||
if feet.z < -0.05 && feet.z > -4.0 {
|
||
if feet.x < 0.0 {
|
||
ooze_ticks += 1;
|
||
} else {
|
||
bridge_ticks += 1;
|
||
}
|
||
}
|
||
reached_far |= feet.z < -4.0;
|
||
}
|
||
assert!(reached_far, "the tour should reach the far platform");
|
||
assert!(
|
||
bridge_ticks > ooze_ticks * 4,
|
||
"took the bridge: {bridge_ticks} bridge vs {ooze_ticks} ooze ticks"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn refuses_to_walk_into_a_pit() {
|
||
let cfg = WalkerConfig { fall_limit: 0.5, repick_secs: 1.0e6, ..cfg() };
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
// A ledge that ends at z = -3 with nothing beyond: a void.
|
||
floor(&mut p, &mut i, -1.0, 1.0, -3.0, 2.0, 0.0);
|
||
floor(&mut p, &mut i, -1.0, 1.0, -3.0, 2.0, 2.0);
|
||
let level = LevelCollision::from_positions(p, i);
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 1.0), 0.0, cfg, 11);
|
||
for _ in 0..900 {
|
||
let ev = w.tick(1.0 / 60.0, &level);
|
||
if ev == WalkerEvent::Stranded {
|
||
panic!("the walker fell out of the level at {:?}", w.feet());
|
||
}
|
||
assert!(w.feet().z > -3.0 - 1e-3, "stepped into the void: {:?}", w.feet());
|
||
assert!((w.feet().y - 0.0).abs() < 1e-3);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn interior_start_is_under_the_roof_not_on_it() {
|
||
let cfg = cfg();
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
floor(&mut p, &mut i, -6.0, 6.0, -6.0, 6.0, 0.0);
|
||
floor(&mut p, &mut i, -6.0, 6.0, -6.0, 6.0, 3.0); // roof
|
||
wall_x(&mut p, &mut i, -6.0, 6.0, -6.0, 0.0, 3.0);
|
||
wall_x(&mut p, &mut i, -6.0, 6.0, 6.0, 0.0, 3.0);
|
||
wall_z(&mut p, &mut i, -6.0, 6.0, -6.0, 0.0, 3.0);
|
||
wall_z(&mut p, &mut i, -6.0, 6.0, 6.0, 0.0, 3.0);
|
||
let level = LevelCollision::from_positions(p, i);
|
||
let start = level.interior_start(&cfg).expect("a room is walkable");
|
||
assert!((start.y - 0.0).abs() < 1e-3, "on the floor, not the roof: {start:?}");
|
||
let mut w = LevelWalker::new(start, 0.0, cfg, 5);
|
||
for _ in 0..1200 {
|
||
w.tick(1.0 / 60.0, &level);
|
||
assert!(w.feet().y < 2.9, "climbed onto the roof: {:?}", w.feet());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn head_bob_is_speed_proportional_and_per_engine() {
|
||
let level = corridor();
|
||
let sample = |style: BobStyle| {
|
||
let cfg = WalkerConfig { bob: style, repick_secs: 1.0e6, ..cfg() };
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 0.5), 0.0, cfg, 3);
|
||
assert_eq!(w.camera().eye.y, 0.0 + cfg.eye_height, "standing still: no bob");
|
||
let (mut lo, mut hi, mut crossings, mut prev) = (f32::MAX, f32::MIN, 0, 0.0f32);
|
||
for i in 0..600 {
|
||
w.tick(1.0 / 60.0, &level);
|
||
let h = w.camera().eye.y - w.feet().y - cfg.eye_height;
|
||
if i > 120 {
|
||
lo = lo.min(h);
|
||
hi = hi.max(h);
|
||
if prev <= 0.0 && h > 0.0 {
|
||
crossings += 1;
|
||
}
|
||
prev = h;
|
||
}
|
||
}
|
||
(lo, hi, crossings)
|
||
};
|
||
let (lo, hi, cycles) = sample(BobStyle::Doom);
|
||
// Magnitude is pinned by `head_bob_stays_within_a_few_centimetres`;
|
||
// here the shape matters: a real dip below the eye line, and the
|
||
// engine's cadence.
|
||
assert!(hi > 0.002, "doom bob rises: {hi}");
|
||
assert!(lo < -0.002, "doom bob dips below the eye line: {lo}");
|
||
assert!((cycles as f32 - 8.0 * 1.75).abs() < 2.5, "doom ~1.75 Hz, {cycles} in 8 s");
|
||
let (_, _, quake_cycles) = sample(BobStyle::Quake);
|
||
assert!(
|
||
(quake_cycles as f32 - 8.0 / 0.6).abs() < 3.0,
|
||
"quake cl_bobcycle 0.6 s, {quake_cycles} in 8 s"
|
||
);
|
||
let (_, duke_hi, _) = sample(BobStyle::Duke);
|
||
assert!(duke_hi > hi, "Build's stride is deeper than Doom's");
|
||
assert_eq!(sample(BobStyle::None).1, 0.0, "bob off means a locked camera");
|
||
}
|
||
|
||
#[test]
|
||
fn bob_style_follows_the_map_source() {
|
||
assert_eq!(BobStyle::from_source("doom/doom/worlds/doom1/e1m1"), BobStyle::Doom);
|
||
assert_eq!(BobStyle::from_source("duke/duke3d/worlds/duke3d/e1l1"), BobStyle::Duke);
|
||
assert_eq!(BobStyle::from_source("quake/id1/worlds/id1/e1m1"), BobStyle::Quake);
|
||
assert_eq!(BobStyle::from_source("quake3/baseq3/worlds/q3dm1"), BobStyle::Quake);
|
||
}
|
||
|
||
#[test]
|
||
fn same_seed_walks_the_same_tour() {
|
||
let level = corridor();
|
||
let cfg = cfg();
|
||
let path = |seed: u64| {
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 0.0), 0.0, cfg, seed);
|
||
(0..900)
|
||
.map(|_| {
|
||
w.tick(1.0 / 60.0, &level);
|
||
(w.feet().x, w.feet().z, w.yaw())
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(path(42), path(42), "a seeded tour repeats exactly");
|
||
}
|
||
|
||
/// Two storeys joined by a staircase: a lower room (y 0, z -2..6), six
|
||
/// 0.25 treads climbing to y 1.5 over z -8..-2, and an upper room
|
||
/// (y 1.5, z -14..-8). Roofed throughout, walled down both sides.
|
||
fn two_floors() -> LevelCollision {
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
floor(&mut p, &mut i, -2.0, 2.0, -2.0, 6.0, 0.0);
|
||
floor(&mut p, &mut i, -2.0, 2.0, -2.0, 6.0, 3.0);
|
||
for n in 0..6 {
|
||
let y = 0.25 * (n + 1) as f32;
|
||
let z1 = -2.0 - n as f32;
|
||
floor(&mut p, &mut i, -2.0, 2.0, z1 - 1.0, z1, y);
|
||
}
|
||
floor(&mut p, &mut i, -2.0, 2.0, -8.0, -2.0, 4.0);
|
||
floor(&mut p, &mut i, -2.0, 2.0, -14.0, -8.0, 1.5);
|
||
floor(&mut p, &mut i, -2.0, 2.0, -14.0, -8.0, 4.5);
|
||
wall_x(&mut p, &mut i, -14.0, 6.0, -2.0, 0.0, 5.0);
|
||
wall_x(&mut p, &mut i, -14.0, 6.0, 2.0, 0.0, 5.0);
|
||
wall_z(&mut p, &mut i, -2.0, 2.0, 6.0, 0.0, 5.0);
|
||
wall_z(&mut p, &mut i, -2.0, 2.0, -14.0, 0.0, 5.0);
|
||
LevelCollision::from_positions(p, i)
|
||
}
|
||
|
||
/// Wrap a glTF JSON document as a GLB (JSON chunk only — the classifier
|
||
/// never touches the binary chunk).
|
||
fn glb(json: &str) -> Vec<u8> {
|
||
let mut chunk = json.as_bytes().to_vec();
|
||
while chunk.len() % 4 != 0 {
|
||
chunk.push(b' ');
|
||
}
|
||
let mut out = Vec::new();
|
||
out.extend_from_slice(b"glTF");
|
||
out.extend_from_slice(&2u32.to_le_bytes());
|
||
out.extend_from_slice(&((12 + 8 + chunk.len()) as u32).to_le_bytes());
|
||
out.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
|
||
out.extend_from_slice(b"JSON");
|
||
out.extend_from_slice(&chunk);
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn hazard_nodes_classify_their_triangles() {
|
||
// The importer's contract: a `hazard_N` node with extras. Three
|
||
// primitives — clean floor, nukage, water — 2 triangles each.
|
||
let doc = r#"{
|
||
"nodes":[
|
||
{"name":"floor_0","mesh":0},
|
||
{"name":"hazard_1","mesh":1,"extras":{"kind":"hazard","damage":5,"flat":"NUKAGE1","liquid":true}},
|
||
{"name":"hazard_2","mesh":2,"extras":{"kind":"hazard","damage":0,"flat":"FWATER1","liquid":true}}
|
||
],
|
||
"meshes":[
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]},
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]},
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]}
|
||
],
|
||
"accessors":[{"count":4},{"count":6}]
|
||
}"#;
|
||
let kinds = surface_kinds_from_glb(&glb(doc), 6).expect("classified");
|
||
assert_eq!(
|
||
kinds,
|
||
vec![
|
||
SurfaceKind::Floor,
|
||
SurfaceKind::Floor,
|
||
SurfaceKind::Hazard,
|
||
SurfaceKind::Hazard,
|
||
SurfaceKind::Liquid,
|
||
SurfaceKind::Liquid,
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn old_maps_fall_back_to_the_flat_name_on_the_material() {
|
||
// Every classic map published before the `hazard_N` lane: the only
|
||
// evidence is the source engine's flat name on the material.
|
||
let doc = r#"{
|
||
"nodes":[{"name":"e1m1","mesh":0}],
|
||
"meshes":[{"primitives":[
|
||
{"attributes":{"POSITION":0},"indices":1,"material":0},
|
||
{"attributes":{"POSITION":0},"indices":1,"material":1},
|
||
{"attributes":{"POSITION":0},"indices":1,"material":2}
|
||
]}],
|
||
"materials":[{"name":"doom.FLOOR4_8"},{"name":"doom.NUKAGE1"},{"name":"doom.FWATER1"}],
|
||
"accessors":[{"count":4},{"count":6}]
|
||
}"#;
|
||
let kinds = surface_kinds_from_glb(&glb(doc), 6).expect("classified");
|
||
assert_eq!(kinds[0], SurfaceKind::Floor);
|
||
assert_eq!(kinds[2], SurfaceKind::Hazard, "NUKAGE damages");
|
||
assert_eq!(kinds[4], SurfaceKind::Liquid, "FWATER is only wet");
|
||
}
|
||
|
||
/// The open-air regression: every classic map with an outdoor area
|
||
/// publishes a `sky` node, the loader takes that geometry OUT of the
|
||
/// static stream, and a walk that still counted it could never
|
||
/// reproduce the model's triangle count — so the whole classification
|
||
/// was discarded and the map's nukage stopped hurting. (Doom's E1M1:
|
||
/// 17264 level + 592 hazard triangles loaded, 502 more under `sky`.)
|
||
#[test]
|
||
fn sky_geometry_is_left_out_of_the_static_walk() {
|
||
let doc = r#"{
|
||
"nodes":[
|
||
{"name":"floor_0","mesh":0},
|
||
{"name":"hazard_1","mesh":1,"extras":{"kind":"hazard","damage":5,"flat":"NUKAGE1"}},
|
||
{"name":"sky","mesh":2,"extras":{"kind":"sky","projection":"cylinder"}}
|
||
],
|
||
"meshes":[
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]},
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]},
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]}
|
||
],
|
||
"accessors":[{"count":4},{"count":6}]
|
||
}"#;
|
||
// The loader packs 4 triangles (floor + hazard); the sky's 2 are its
|
||
// own. Classification must agree, and the hazard must survive.
|
||
let kinds = surface_kinds_from_glb(&glb(doc), 4).expect("classified without the sky");
|
||
assert_eq!(
|
||
kinds,
|
||
vec![
|
||
SurfaceKind::Floor,
|
||
SurfaceKind::Floor,
|
||
SurfaceKind::Hazard,
|
||
SurfaceKind::Hazard,
|
||
]
|
||
);
|
||
}
|
||
|
||
/// A node NAMED sky with no projection the engine knows is ordinary
|
||
/// geometry to the loader, so it must stay ordinary here too.
|
||
#[test]
|
||
fn a_sky_node_without_a_projection_is_ordinary_geometry() {
|
||
let doc = r#"{
|
||
"nodes":[
|
||
{"name":"hazard_1","mesh":0,"extras":{"kind":"hazard","damage":5}},
|
||
{"name":"sky","mesh":1,"extras":{"kind":"sky"}}
|
||
],
|
||
"meshes":[
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]},
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]}
|
||
],
|
||
"accessors":[{"count":4},{"count":6}]
|
||
}"#;
|
||
let kinds = surface_kinds_from_glb(&glb(doc), 4).expect("classified with the sky in");
|
||
assert_eq!(kinds.len(), 4);
|
||
assert_eq!(kinds[0], SurfaceKind::Hazard);
|
||
}
|
||
|
||
#[test]
|
||
fn a_triangle_count_that_disagrees_with_the_loader_is_refused() {
|
||
// The contract check: if this walk does not reproduce the loader's
|
||
// triangle count, the mapping is a guess and must not be used.
|
||
let doc = r#"{
|
||
"nodes":[{"name":"hazard_1","mesh":0,"extras":{"kind":"hazard","damage":5}}],
|
||
"meshes":[{"primitives":[{"attributes":{"POSITION":0},"indices":1}]}],
|
||
"accessors":[{"count":4},{"count":6}]
|
||
}"#;
|
||
assert!(surface_kinds_from_glb(&glb(doc), 2).is_some(), "matching count is used");
|
||
assert_eq!(surface_kinds_from_glb(&glb(doc), 99), None, "a mismatch is refused");
|
||
assert_eq!(surface_kinds_from_glb(b"not a glb", 2), None);
|
||
}
|
||
|
||
#[test]
|
||
fn door_geometry_is_left_out_of_the_static_walk() {
|
||
// A door node (`extras.states`) is a moving part: its triangles are
|
||
// NOT in the static stream, so the classifier must skip them too or
|
||
// every triangle after it is labelled with its neighbour's kind.
|
||
let doc = r#"{
|
||
"nodes":[
|
||
{"name":"door_1","mesh":0,"extras":{"states":["closed","open"],"default":"closed"}},
|
||
{"name":"hazard_1","mesh":1,"extras":{"kind":"hazard","damage":5}}
|
||
],
|
||
"meshes":[
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]},
|
||
{"primitives":[{"attributes":{"POSITION":0},"indices":1}]}
|
||
],
|
||
"animations":[{"name":"door_1","channels":[]}],
|
||
"accessors":[{"count":4},{"count":6}]
|
||
}"#;
|
||
let kinds = surface_kinds_from_glb(&glb(doc), 2).expect("classified without the door");
|
||
assert_eq!(kinds, vec![SurfaceKind::Hazard, SurfaceKind::Hazard]);
|
||
}
|
||
|
||
#[test]
|
||
fn nav_grid_connects_two_floors_through_the_stairs() {
|
||
let cfg = cfg();
|
||
let level = two_floors();
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
assert!(nav.len() > 100, "a two-room level has cells: {}", nav.len());
|
||
assert!(nav.edge_count() > nav.len(), "cells are linked: {}", nav.edge_count());
|
||
// Both storeys are represented.
|
||
let lower = (0..nav.len() as u32)
|
||
.find(|i| {
|
||
let c = nav.cell(*i).unwrap();
|
||
c.pos.y.abs() < 1e-3 && c.pos.z > 4.0
|
||
})
|
||
.expect("a cell at the back of the lower room");
|
||
let upper: Vec<u32> = (0..nav.len() as u32)
|
||
.filter(|i| {
|
||
let c = nav.cell(*i).unwrap();
|
||
(c.pos.y - 1.5).abs() < 1e-3 && c.pos.z < -9.0
|
||
})
|
||
.collect();
|
||
assert!(!upper.is_empty(), "the upper room has cells");
|
||
// The graph itself crosses the stairs.
|
||
let (mut dist, mut parent) = (Vec::new(), Vec::new());
|
||
let reached = nav.flood(lower, &std::collections::HashSet::new(), &mut dist, &mut parent);
|
||
assert!(reached > nav.len() / 2, "most of the level is reachable: {reached}/{}", nav.len());
|
||
for u in &upper {
|
||
assert_ne!(
|
||
dist[*u as usize], NAV_UNREACHED,
|
||
"upper-room cell {u} is cut off from the lower room"
|
||
);
|
||
}
|
||
// And the route it hands out actually walks up them.
|
||
let path = nav.path_to(upper[0], &parent);
|
||
assert!(!path.is_empty(), "a route exists");
|
||
let climbed = path
|
||
.windows(2)
|
||
.all(|w| {
|
||
let (a, b) = (nav.cell(w[0]).unwrap(), nav.cell(w[1]).unwrap());
|
||
b.pos.y - a.pos.y <= cfg.step_up + 1e-3
|
||
});
|
||
assert!(climbed, "no waypoint pair asks for a jump");
|
||
let top = path.iter().map(|c| nav.cell(*c).unwrap().pos.y).fold(0.0f32, f32::max);
|
||
assert!(top >= 1.5 - 1e-3, "the route reaches the upper storey: {top}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_tour_climbs_to_the_other_floor_and_keeps_finding_new_ground() {
|
||
// The complaint this exists for: the local heading picker paced up
|
||
// and down one corridor. With a plan it must cross the whole map.
|
||
let cfg = cfg();
|
||
let level = two_floors();
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 5.0), 0.0, cfg, 9);
|
||
let (mut top, mut bottom) = (f32::MIN, f32::MAX);
|
||
let mut distinct_at_30s = 0;
|
||
for t in 0..7200 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
top = top.max(w.feet().y);
|
||
bottom = bottom.min(w.feet().y);
|
||
if t == 1800 {
|
||
distinct_at_30s = w.nav_stats().distinct;
|
||
}
|
||
}
|
||
let stats = w.nav_stats();
|
||
assert!(top >= 1.4, "the tour climbed to the upper room: reached y={top}");
|
||
assert!(bottom <= 0.1, "and it started on the lower one: {bottom}");
|
||
assert!(stats.replans >= 2, "it re-planned as it went: {stats:?}");
|
||
// Coverage keeps GROWING — the ping-pong signature is a distinct
|
||
// count that flattens out after a few seconds.
|
||
assert!(
|
||
stats.distinct > distinct_at_30s,
|
||
"coverage stalled: {distinct_at_30s} cells at 30 s, {} at 120 s",
|
||
stats.distinct
|
||
);
|
||
assert!(
|
||
stats.distinct * 3 > nav.len(),
|
||
"toured only {} of {} cells",
|
||
stats.distinct,
|
||
nav.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_planned_tour_still_prefers_the_bridge_to_the_nukage() {
|
||
let cfg = WalkerConfig { repick_secs: 2.0, ..cfg() };
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
let mut kinds = Vec::new();
|
||
let push = |p: &mut Vec<Vec3f>,
|
||
i: &mut Vec<u32>,
|
||
kinds: &mut Vec<SurfaceKind>,
|
||
x0: f32,
|
||
x1: f32,
|
||
z0: f32,
|
||
z1: f32,
|
||
y: f32,
|
||
kind: SurfaceKind| {
|
||
floor(p, i, x0, x1, z0, z1, y);
|
||
kinds.push(kind);
|
||
kinds.push(kind);
|
||
};
|
||
push(&mut p, &mut i, &mut kinds, -3.0, 3.0, 0.0, 2.0, 0.0, SurfaceKind::Floor);
|
||
push(&mut p, &mut i, &mut kinds, -3.0, 3.0, -6.0, -4.0, 0.0, SurfaceKind::Floor);
|
||
push(&mut p, &mut i, &mut kinds, -3.0, -1.0, -4.0, 0.0, 0.0, SurfaceKind::Hazard);
|
||
push(&mut p, &mut i, &mut kinds, 1.0, 3.0, -4.0, 0.0, 0.0, SurfaceKind::Floor);
|
||
push(&mut p, &mut i, &mut kinds, -3.0, 3.0, -6.0, 2.0, 4.0, SurfaceKind::Floor);
|
||
let level = LevelCollision::from_positions(p, i).with_kinds(kinds);
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
// The grid knows which corridor is which.
|
||
let ooze = (0..nav.len() as u32)
|
||
.filter(|c| nav.cell(*c).unwrap().kind == SurfaceKind::Hazard)
|
||
.count();
|
||
assert!(ooze > 0, "the hazard strip is in the grid, not deleted");
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 1.0), 0.0, cfg, 4);
|
||
let (mut ooze_ticks, mut bridge_ticks, mut reached_far) = (0, 0, false);
|
||
for _ in 0..5400 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
let feet = w.feet();
|
||
if feet.z < -0.05 && feet.z > -4.0 {
|
||
if feet.x < 0.0 {
|
||
ooze_ticks += 1;
|
||
} else {
|
||
bridge_ticks += 1;
|
||
}
|
||
}
|
||
reached_far |= feet.z < -4.0;
|
||
}
|
||
assert!(reached_far, "the planned tour reaches the far platform");
|
||
assert!(
|
||
bridge_ticks > ooze_ticks * 4,
|
||
"took the bridge: {bridge_ticks} bridge vs {ooze_ticks} ooze ticks"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_seeded_planned_tour_repeats_exactly() {
|
||
let level = two_floors();
|
||
let cfg = cfg();
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
let path = |seed: u64| {
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 5.0), 0.0, cfg, seed);
|
||
(0..1800)
|
||
.map(|_| {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
(w.feet().x, w.feet().y, w.feet().z, w.yaw())
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(path(42), path(42), "a seeded planned tour repeats exactly");
|
||
assert_ne!(path(42), path(43), "and two seeds do not walk in lockstep");
|
||
}
|
||
|
||
#[test]
|
||
fn a_stuck_leg_is_invalidated_and_replanned() {
|
||
// A wall the probe cannot see (the fixture lies): the walker must
|
||
// notice it makes no ground, drop that edge and route elsewhere.
|
||
let cfg = cfg();
|
||
let level = two_floors();
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 5.0), 0.0, cfg, 5);
|
||
// Freeze the body: every tick reports no progress, which is exactly
|
||
// what a lying edge looks like from inside the walker.
|
||
for _ in 0..600 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
w.pos = vec3f(0.0, 0.0, 5.0);
|
||
}
|
||
let stats = w.nav_stats();
|
||
assert!(stats.invalidated > 0, "a leg that never moves is dropped: {stats:?}");
|
||
assert!(stats.replans > 1, "and the tour re-plans: {stats:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn doors_gate_their_cells_and_are_asked_for_by_the_tour() {
|
||
let cfg = cfg();
|
||
let level = two_floors();
|
||
let mut nav = NavGrid::build(&level, &cfg);
|
||
// A door across the whole lower room at z ≈ 0.
|
||
nav.mark_doors(&[(vec3f(-2.0, 0.0, -0.4), vec3f(2.0, 2.0, 0.4))]);
|
||
let gated = (0..nav.len() as u32)
|
||
.filter(|c| nav.cell(*c).unwrap().door == Some(0))
|
||
.count();
|
||
assert!(gated > 0, "the door's footprint marks cells");
|
||
for c in 0..nav.len() as u32 {
|
||
let cell = nav.cell(c).unwrap();
|
||
if cell.door.is_some() {
|
||
assert!(cell.pos.z.abs() < 1.0, "only the doorway is gated: {cell:?}");
|
||
}
|
||
}
|
||
// Walking toward the stairs, the tour asks for the door ahead.
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 5.0), 0.0, cfg, 3);
|
||
let mut asked = None;
|
||
for _ in 0..3600 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
if let Some(d) = w.wanted_door() {
|
||
asked = Some(d);
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(asked, Some(0), "the tour asked the host to open door 0");
|
||
// Once the host says it is open, the request clears and it walks on.
|
||
w.set_door_open(0, true);
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
assert_eq!(w.wanted_door(), None, "an open door is no longer requested");
|
||
let before = w.feet().z;
|
||
for _ in 0..600 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
}
|
||
assert!(w.feet().z < before, "and it carries on through: {before} → {:?}", w.feet());
|
||
}
|
||
|
||
/// Two sealed rooms with no way between them — exactly what a classic
|
||
/// map looks like while its doors are still baked into the walls.
|
||
fn two_sealed_rooms() -> LevelCollision {
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
for (z0, z1) in [(0.0f32, 6.0f32), (-20.0, -14.0)] {
|
||
floor(&mut p, &mut i, -3.0, 3.0, z0, z1, 0.0);
|
||
floor(&mut p, &mut i, -3.0, 3.0, z0, z1, 2.5);
|
||
wall_x(&mut p, &mut i, z0, z1, -3.0, 0.0, 2.5);
|
||
wall_x(&mut p, &mut i, z0, z1, 3.0, 0.0, 2.5);
|
||
wall_z(&mut p, &mut i, -3.0, 3.0, z0, 0.0, 2.5);
|
||
wall_z(&mut p, &mut i, -3.0, 3.0, z1, 0.0, 2.5);
|
||
}
|
||
LevelCollision::from_positions(p, i)
|
||
}
|
||
|
||
/// Diagnostic against a REAL published map. Skipped unless `LEVEL_GLB`
|
||
/// points at one, so the suite stays hermetic; run as
|
||
/// `LEVEL_GLB=<path> cargo test -p makepad-render --lib -- real_level --nocapture`
|
||
/// when a map on screen behaves in a way the fixtures do not reproduce.
|
||
#[test]
|
||
fn real_level_is_a_place_a_walker_can_get_out_of() {
|
||
let Ok(path) = std::env::var("LEVEL_GLB") else { return };
|
||
let bytes = std::fs::read(&path).expect("LEVEL_GLB readable");
|
||
let model = crate::StaticModel::parse_glb(&bytes).expect("a static GLB");
|
||
let cfg = WalkerConfig::default();
|
||
let level = LevelCollision::from_packed(
|
||
&model.vertices,
|
||
crate::model::MODEL_VERTEX_FLOATS,
|
||
&model.indices,
|
||
UpAxis::Y,
|
||
)
|
||
.expect("collision");
|
||
let level = match surface_kinds_from_glb(&bytes, model.triangle_count()) {
|
||
Some(kinds) => {
|
||
let h = kinds.iter().filter(|k| **k == SurfaceKind::Hazard).count();
|
||
println!("kinds: {h} hazard of {} triangles", kinds.len());
|
||
level.with_kinds(kinds)
|
||
}
|
||
None => {
|
||
println!("kinds: none (no hazard_N nodes, no flat names)");
|
||
level
|
||
}
|
||
};
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
let r = nav.refusals();
|
||
println!(
|
||
"nav: {} cells, {} edges, refused {} too tall (smallest rise {:.4}) / {} deep / {} walled, {} escape links",
|
||
nav.len(),
|
||
nav.edge_count(),
|
||
r.too_tall,
|
||
r.smallest_refused_rise,
|
||
r.too_deep,
|
||
r.walled,
|
||
r.escapes
|
||
);
|
||
println!("components: {:?}", &nav.component_sizes()[..nav.component_sizes().len().min(8)]);
|
||
let core = nav.best_start().expect("a start");
|
||
let out = nav.can_reach(core);
|
||
let trapped = out.iter().filter(|o| !**o).count();
|
||
println!("trapped cells (cannot walk back to the core): {trapped}/{}", nav.len());
|
||
// What actually separates a trapped cell from freedom: the rise to
|
||
// its nearest free neighbour, and whether the body probe refuses it.
|
||
let mut rises: Vec<(f32, bool)> = Vec::new();
|
||
for a in 0..nav.len() {
|
||
if out[a] {
|
||
continue;
|
||
}
|
||
let (ix, iz) = nav.column_xz(nav.cells[a].column);
|
||
for dz in -1i32..=1 {
|
||
for dx in -1i32..=1 {
|
||
if dx == 0 && dz == 0 {
|
||
continue;
|
||
}
|
||
let Some(col) = nav.column_index(ix as i32 + dx, iz as i32 + dz) else {
|
||
continue;
|
||
};
|
||
for b in nav.column_cells(col) {
|
||
if !out[b] {
|
||
continue;
|
||
}
|
||
let dy = nav.cells[b].pos.y - nav.cells[a].pos.y;
|
||
if dy <= 0.0 {
|
||
continue;
|
||
}
|
||
let blocked = level.path_blocked(
|
||
nav.cells[a].pos,
|
||
nav.cells[b].pos,
|
||
cfg.radius,
|
||
(cfg.step_up + hazard_escape_step(cfg.step_up)).min(cfg.height * 0.8),
|
||
cfg.height,
|
||
);
|
||
rises.push((dy, blocked));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
rises.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||
println!("trapped→free boundary pairs: {}", rises.len());
|
||
for (dy, blocked) in rises.iter().take(12) {
|
||
println!(" rise {dy:.4} ({} map units) blocked={blocked}", (dy * 64.0).round());
|
||
}
|
||
let climbable = rises
|
||
.iter()
|
||
.filter(|(dy, blocked)| *dy <= cfg.step_up + STEP_EPS + hazard_escape_step(cfg.step_up) && !*blocked)
|
||
.count();
|
||
println!(" of those, {climbable} are within the escape step and unblocked");
|
||
// Drop the body in the LOWEST floor of the map — the bottom of the
|
||
// deepest pit — and see whether it is a place you can leave.
|
||
let low = (0..nav.len() as u32)
|
||
.min_by(|a, b| {
|
||
nav.cell(*a)
|
||
.unwrap()
|
||
.pos
|
||
.y
|
||
.partial_cmp(&nav.cell(*b).unwrap().pos.y)
|
||
.unwrap()
|
||
})
|
||
.expect("cells");
|
||
let start = nav.cell(low).unwrap().pos;
|
||
println!("deepest cell {low} at {start:?}, escape flag {}", nav.cell(low).unwrap().escape);
|
||
let mut w = LevelWalker::new(start, 0.0, cfg, 3);
|
||
let (mut top, mut left) = (start.y, false);
|
||
for t in 0..3600 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
top = top.max(w.feet().y);
|
||
if !left && w.feet().y > start.y + 0.3 {
|
||
left = true;
|
||
println!("climbed out after {:.1}s at {:?}", t as f32 / 60.0, w.feet());
|
||
}
|
||
}
|
||
println!(
|
||
"after 60 s: {:?}, highest {top:.3} (started {:.3}), stats {:?}",
|
||
w.feet(),
|
||
start.y,
|
||
w.nav_stats()
|
||
);
|
||
// EVERY low-lying cell, not just the single deepest: a pit that is a
|
||
// prison anywhere on the map is the bug the user is looking at.
|
||
let mut low_cells: Vec<u32> = (0..nav.len() as u32).collect();
|
||
low_cells.sort_by(|a, b| {
|
||
nav.cell(*a)
|
||
.unwrap()
|
||
.pos
|
||
.y
|
||
.partial_cmp(&nav.cell(*b).unwrap().pos.y)
|
||
.unwrap()
|
||
});
|
||
let sample: Vec<u32> = low_cells.iter().step_by(7).take(40).copied().collect();
|
||
let mut prisons = Vec::new();
|
||
for c in &sample {
|
||
let from = nav.cell(*c).unwrap().pos;
|
||
let mut w = LevelWalker::new(from, 0.0, cfg, 5);
|
||
let mut out_ok = false;
|
||
for _ in 0..1800 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
if w.feet().y > from.y + 0.3 || w.cut_count() > 0 {
|
||
out_ok = true;
|
||
break;
|
||
}
|
||
}
|
||
if !out_ok {
|
||
prisons.push((*c, from, w.feet()));
|
||
}
|
||
}
|
||
println!(
|
||
"pit sample: {} of {} low starts could not rise 0.3 in 30 s",
|
||
prisons.len(),
|
||
sample.len()
|
||
);
|
||
for (c, from, at) in prisons.iter().take(3) {
|
||
println!(" cell {c} start {from:?} ended {at:?} escape={}", nav.cell(*c).unwrap().escape);
|
||
// Which graph edge leads UP out of here, and what does the BODY
|
||
// say when it tries to take it from a few distances back?
|
||
let up: Vec<(u32, f32)> = nav
|
||
.edges(*c)
|
||
.map(|(b, _)| (b, nav.cell(b).unwrap().pos.y - from.y))
|
||
.filter(|(_, dy)| *dy > 0.05)
|
||
.collect();
|
||
println!(" graph edges rising out of it: {up:?}");
|
||
// Can the graph route from here up to the core at all, and how far?
|
||
let (mut d, mut par) = (Vec::new(), Vec::new());
|
||
nav.flood(*c, &std::collections::HashSet::new(), &mut d, &mut par);
|
||
match d.get(core as usize).copied() {
|
||
Some(cost) if cost != NAV_UNREACHED => {
|
||
let route = nav.path_to(core, &par);
|
||
let rises: Vec<f32> = route
|
||
.windows(2)
|
||
.map(|w| nav.cell(w[1]).unwrap().pos.y - nav.cell(w[0]).unwrap().pos.y)
|
||
.collect();
|
||
let max_rise = rises.iter().cloned().fold(0.0f32, f32::max);
|
||
println!(
|
||
" route to the core: cost {cost}, {} waypoints, biggest single rise {max_rise:.3}",
|
||
route.len()
|
||
);
|
||
// The first RISING pair on the route is the step the body
|
||
// has to take. Ask the body about it, from a few
|
||
// distances back, exactly as it would arrive.
|
||
let full: Vec<u32> = std::iter::once(*c).chain(route.iter().copied()).collect();
|
||
if let Some(k) = full.windows(2).position(|w| {
|
||
nav.cell(w[1]).unwrap().pos.y - nav.cell(w[0]).unwrap().pos.y > 0.05
|
||
}) {
|
||
let (pa, pb) =
|
||
(nav.cell(full[k]).unwrap().pos, nav.cell(full[k + 1]).unwrap().pos);
|
||
let (dx, dz) = (pb.x - pa.x, pb.z - pa.z);
|
||
let len = (dx * dx + dz * dz).sqrt().max(1e-6);
|
||
println!(
|
||
" rising pair on the route: {pa:?} -> {pb:?} (rise {:.4})",
|
||
pb.y - pa.y
|
||
);
|
||
for back in [0.4f32, 0.25, 0.1, 0.0, -0.1] {
|
||
let at =
|
||
vec3f(pa.x - dx / len * back, pa.y, pa.z - dz / len * back);
|
||
let probe = LevelWalker::new(at, dx.atan2(-dz), cfg, 1);
|
||
let step = cfg.speed / 60.0;
|
||
let r = probe
|
||
.step_refusal(&level, vec3f(dx / len * step, 0.0, dz / len * step));
|
||
println!(" {back:+.2} back: {r:?}");
|
||
}
|
||
}
|
||
}
|
||
_ => println!(" NO route to the core from here"),
|
||
}
|
||
for (b, dy) in up.iter().take(2) {
|
||
let to = nav.cell(*b).unwrap().pos;
|
||
let (dx, dz) = (to.x - from.x, to.z - from.z);
|
||
let len = (dx * dx + dz * dz).sqrt().max(1e-6);
|
||
for back in [0.30f32, 0.15, 0.05, 0.0] {
|
||
let at = vec3f(from.x - dx / len * back, from.y, from.z - dz / len * back);
|
||
let mut probe = LevelWalker::new(at, dx.atan2(-dz), cfg, 1);
|
||
let step = cfg.speed / 60.0;
|
||
let r = probe.step_refusal(&level, vec3f(dx / len * step, 0.0, dz / len * step));
|
||
println!(" rise {dy:.3}, {back:.2} back: {r:?}");
|
||
let _ = &mut probe;
|
||
}
|
||
}
|
||
}
|
||
assert!(left, "the deepest pit in {path} is a prison");
|
||
}
|
||
|
||
/// Measurement against a REAL published map: every rising step between
|
||
/// adjacent grid columns (a stair tread by Doom's own step rule), judged
|
||
/// the three ways a step can be judged — by the GRAPH (is the edge
|
||
/// offered?), by the BODY from the far cell centre (`step_refusal` over
|
||
/// the whole edge), and by the BODY arriving incrementally (per-tick
|
||
/// probes marched along the edge, which is how the walker actually
|
||
/// travels). Prints a per-flight table so "the tour avoids stairs" can
|
||
/// be split into geometry (risers/lips walling the probes) versus
|
||
/// policy (the planner not wanting to). Skipped unless `LEVEL_GLB`
|
||
/// points at a map:
|
||
/// `LEVEL_GLB=<path> cargo test -p makepad-render --release --lib -- real_level_stair --nocapture`
|
||
#[test]
|
||
fn real_level_stair_steps_agree_between_graph_and_body() {
|
||
let Ok(path) = std::env::var("LEVEL_GLB") else { return };
|
||
let bytes = std::fs::read(&path).expect("LEVEL_GLB readable");
|
||
let model = crate::StaticModel::parse_glb(&bytes).expect("a static GLB");
|
||
let cfg = WalkerConfig::default();
|
||
let level = LevelCollision::from_packed(
|
||
&model.vertices,
|
||
crate::model::MODEL_VERTEX_FLOATS,
|
||
&model.indices,
|
||
UpAxis::Y,
|
||
)
|
||
.expect("collision");
|
||
let level = match surface_kinds_from_glb(&bytes, model.triangle_count()) {
|
||
Some(kinds) => level.with_kinds(kinds),
|
||
None => level,
|
||
};
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
let tick = cfg.speed / 60.0;
|
||
|
||
// The body marching one edge exactly as the walker travels it:
|
||
// per-tick `step_refusal`, advancing over any step it accepts.
|
||
// Returns the first refusal (with how far along it happened).
|
||
let march = |from: Vec3f, to: Vec3f| -> (StepRefusal, f32) {
|
||
let (dx, dz) = (to.x - from.x, to.z - from.z);
|
||
let len = (dx * dx + dz * dz).sqrt().max(1e-6);
|
||
let dir = vec3f(dx / len, 0.0, dz / len);
|
||
let yaw = dir.x.atan2(-dir.z);
|
||
let mut at = from;
|
||
let mut travelled = 0.0f32;
|
||
while travelled < len + cfg.radius {
|
||
let probe = LevelWalker::new(at, yaw, cfg, 1);
|
||
let r = probe.step_refusal(&level, vec3f(dir.x * tick, 0.0, dir.z * tick));
|
||
match r {
|
||
StepRefusal::Ok { .. } => {}
|
||
other => return (other, travelled),
|
||
}
|
||
let want = vec3f(at.x + dir.x * tick, at.y, at.z + dir.z * tick);
|
||
let up = cfg.step_up + STEP_EPS + hazard_escape_step(cfg.step_up);
|
||
let Some(floor) =
|
||
level.floor_below(vec3f(want.x, want.y + up, want.z), up + cfg.fall_limit)
|
||
else {
|
||
return (StepRefusal::NoFloor, travelled);
|
||
};
|
||
at = vec3f(want.x, floor.y, want.z);
|
||
travelled += tick;
|
||
}
|
||
(StepRefusal::Ok { rise: to.y - from.y }, travelled)
|
||
};
|
||
// Where exactly is the obstruction a clearance refusal saw? Scan the
|
||
// radial rays upward: the highest ray height (above the stand) that
|
||
// still hits something within the body radius is the lip's top.
|
||
let lip_top = |stand: Vec3f| -> Option<f32> {
|
||
let mut top = None;
|
||
let mut h = 0.02f32;
|
||
while h < cfg.height {
|
||
let origin = vec3f(stand.x, stand.y + h, stand.z);
|
||
for i in 0..8 {
|
||
let a = std::f32::consts::TAU * i as f32 / 8.0;
|
||
if level.caster.any_hit(origin, vec3f(a.sin(), 0.0, -a.cos()), cfg.radius) {
|
||
top = Some(h);
|
||
break;
|
||
}
|
||
}
|
||
h += 0.01;
|
||
}
|
||
top
|
||
};
|
||
|
||
// Every rising pair between adjacent (orthogonal) columns whose rise
|
||
// is a legal step: these ARE the stairs of the map.
|
||
let mut pairs: Vec<(u32, u32)> = Vec::new();
|
||
for a in 0..nav.len() {
|
||
let (ix, iz) = nav.column_xz(nav.cells[a].column);
|
||
for (dx, dz) in [(1i32, 0i32), (0, 1), (-1, 0), (0, -1)] {
|
||
let Some(col) = nav.column_index(ix as i32 + dx, iz as i32 + dz) else {
|
||
continue;
|
||
};
|
||
for b in nav.column_cells(col) {
|
||
let dy = nav.cells[b].pos.y - nav.cells[a].pos.y;
|
||
if dy >= 0.1 && dy <= cfg.step_up + STEP_EPS {
|
||
pairs.push((a as u32, b as u32));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Group the rising pairs into flights (connected stair edges): a
|
||
// per-flight refusal table reads like the staircase on screen.
|
||
let mut flight = vec![usize::MAX; nav.len()];
|
||
let mut nflights = 0usize;
|
||
for (a, b) in &pairs {
|
||
let (fa, fb) = (flight[*a as usize], flight[*b as usize]);
|
||
match (fa, fb) {
|
||
(usize::MAX, usize::MAX) => {
|
||
flight[*a as usize] = nflights;
|
||
flight[*b as usize] = nflights;
|
||
nflights += 1;
|
||
}
|
||
(f, usize::MAX) => flight[*b as usize] = f,
|
||
(usize::MAX, f) => flight[*a as usize] = f,
|
||
(f, g) if f != g => {
|
||
for s in flight.iter_mut() {
|
||
if *s == g {
|
||
*s = f;
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
#[derive(Default)]
|
||
struct Tally {
|
||
steps: usize,
|
||
graph_missing: usize,
|
||
up_walled: usize,
|
||
up_no_clear: usize,
|
||
up_other: usize,
|
||
down_walled: usize,
|
||
down_no_clear: usize,
|
||
down_other: usize,
|
||
lip_tops: Vec<f32>,
|
||
sample: Option<Vec3f>,
|
||
}
|
||
let mut tallies: std::collections::HashMap<usize, Tally> =
|
||
std::collections::HashMap::new();
|
||
for (a, b) in &pairs {
|
||
let f = flight[*a as usize];
|
||
let t = tallies.entry(f).or_default();
|
||
t.steps += 1;
|
||
let (pa, pb) = (nav.cells[*a as usize].pos, nav.cells[*b as usize].pos);
|
||
t.sample.get_or_insert(pa);
|
||
if !nav.edges(*a).any(|(c, _)| c == *b) {
|
||
t.graph_missing += 1;
|
||
}
|
||
for (from, to, walled, no_clear, other) in [
|
||
(pa, pb, &mut t.up_walled, &mut t.up_no_clear, &mut t.up_other),
|
||
(pb, pa, &mut t.down_walled, &mut t.down_no_clear, &mut t.down_other),
|
||
] {
|
||
let (r, at) = march(from, to);
|
||
match r {
|
||
StepRefusal::Ok { .. } => {}
|
||
StepRefusal::Walled => *walled += 1,
|
||
StepRefusal::NoClearance { .. } => {
|
||
*no_clear += 1;
|
||
let (dx, dz) = (to.x - from.x, to.z - from.z);
|
||
let len = (dx * dx + dz * dz).sqrt().max(1e-6);
|
||
let spot = vec3f(
|
||
from.x + dx / len * at,
|
||
from.y.max(to.y),
|
||
from.z + dz / len * at,
|
||
);
|
||
if let Some(top) = lip_top(spot) {
|
||
t.lip_tops.push(top);
|
||
}
|
||
}
|
||
_ => *other += 1,
|
||
}
|
||
}
|
||
}
|
||
let mut rows: Vec<(usize, Tally)> = tallies.into_iter().collect();
|
||
rows.sort_by(|x, y| y.1.steps.cmp(&x.1.steps));
|
||
let mut total = Tally::default();
|
||
for (_, t) in &rows {
|
||
total.steps += t.steps;
|
||
total.graph_missing += t.graph_missing;
|
||
total.up_walled += t.up_walled;
|
||
total.up_no_clear += t.up_no_clear;
|
||
total.up_other += t.up_other;
|
||
total.down_walled += t.down_walled;
|
||
total.down_no_clear += t.down_no_clear;
|
||
total.down_other += t.down_other;
|
||
total.lip_tops.extend_from_slice(&t.lip_tops);
|
||
}
|
||
println!(
|
||
"stair steps: {} in {} flights; graph refused {}; body UP walled {} / no-clearance {} / other {}; DOWN walled {} / no-clearance {} / other {}",
|
||
total.steps,
|
||
rows.len(),
|
||
total.graph_missing,
|
||
total.up_walled,
|
||
total.up_no_clear,
|
||
total.up_other,
|
||
total.down_walled,
|
||
total.down_no_clear,
|
||
total.down_other,
|
||
);
|
||
println!(" flight steps graph- up W/NC/o down W/NC/o near");
|
||
for (f, t) in rows.iter().take(20) {
|
||
println!(
|
||
" {:>6} {:>5} {:>6} {:>2}/{:>2}/{:>2} {:>2}/{:>2}/{:>2} {:?}",
|
||
f,
|
||
t.steps,
|
||
t.graph_missing,
|
||
t.up_walled,
|
||
t.up_no_clear,
|
||
t.up_other,
|
||
t.down_walled,
|
||
t.down_no_clear,
|
||
t.down_other,
|
||
t.sample.unwrap_or(vec3f(0.0, 0.0, 0.0)),
|
||
);
|
||
}
|
||
// The graph-refused pairs, dissected: which probe ray said Walled,
|
||
// how far along it hit, and the smallest knee height that passes.
|
||
let mut shown = 0;
|
||
for (a, b) in &pairs {
|
||
if nav.edges(*a).any(|(c, _)| c == *b) || shown >= 24 {
|
||
continue;
|
||
}
|
||
shown += 1;
|
||
let (pa, pb) = (nav.cells[*a as usize].pos, nav.cells[*b as usize].pos);
|
||
let dy = pb.y - pa.y;
|
||
let (dx, dz) = (pb.x - pa.x, pb.z - pa.z);
|
||
let dist = (dx * dx + dz * dz).sqrt().max(1e-6);
|
||
let dir = vec3f(dx / dist, 0.0, dz / dist);
|
||
let side = vec3f(-dir.z, 0.0, dir.x);
|
||
let knee = cfg.step_up + (cfg.height - cfg.step_up) * 0.15;
|
||
let mut hits = String::new();
|
||
for (label, h) in [("knee", knee), ("chest", cfg.height * 0.85)] {
|
||
for l in [-cfg.radius, 0.0, cfg.radius] {
|
||
let origin = vec3f(pa.x + side.x * l, pa.y + h, pa.z + side.z * l);
|
||
if let Some((t, _)) = level.caster.nearest_hit(origin, dir, dist + cfg.radius)
|
||
{
|
||
hits.push_str(&format!(" {label}@{l:+.2} t={t:.2}"));
|
||
}
|
||
}
|
||
}
|
||
let mut min_knee = None;
|
||
let mut h = knee;
|
||
while h < cfg.height * 0.85 {
|
||
if !level.path_blocked(pa, pb, cfg.radius, h, cfg.height) {
|
||
// path_blocked derives its own ray height from the step
|
||
// argument; report the raw ray height that passed.
|
||
min_knee = Some(knee_height(h, cfg.height));
|
||
break;
|
||
}
|
||
h += 0.02;
|
||
}
|
||
println!(
|
||
" graph-refused {}->{} rise {dy:.3} at {pa:?}:{hits} (lowest passing ray {min_knee:?})",
|
||
a, b
|
||
);
|
||
}
|
||
if !total.lip_tops.is_empty() {
|
||
let mut tops = total.lip_tops.clone();
|
||
tops.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||
println!(
|
||
" no-clearance lip tops above the stand: min {:.3} median {:.3} max {:.3} ({} measured)",
|
||
tops[0],
|
||
tops[tops.len() / 2],
|
||
tops[tops.len() - 1],
|
||
tops.len()
|
||
);
|
||
let knee = knee_height(cfg.step_up, cfg.height);
|
||
let under = tops.iter().filter(|t| **t < knee).count();
|
||
println!(
|
||
" of those, {under} sit under the shared knee height {knee:.3} (tolerated once the band probes agree)"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_nukage_pit_with_a_one_step_rim_is_a_place_you_can_leave() {
|
||
// Doom's rule is step <= 24 map units, INCLUSIVE, and a sector floor
|
||
// baked through a float pipeline lands a hair over as often as under.
|
||
// A rim exactly one step tall used to read as a wall, and a walker in
|
||
// the green swamp was walled into it.
|
||
let cfg = WalkerConfig::default(); // the real Doom body: step 0.375
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
let mut kinds = Vec::new();
|
||
let slab = |p: &mut Vec<Vec3f>,
|
||
i: &mut Vec<u32>,
|
||
kinds: &mut Vec<SurfaceKind>,
|
||
x0: f32,
|
||
x1: f32,
|
||
z0: f32,
|
||
z1: f32,
|
||
y: f32,
|
||
kind: SurfaceKind| {
|
||
floor(p, i, x0, x1, z0, z1, y);
|
||
kinds.push(kind);
|
||
kinds.push(kind);
|
||
};
|
||
// A sunken nukage pool (z -4..0) with clean ground either side, one
|
||
// step up — and the step is the float the importer actually bakes.
|
||
let rim = 0.375_f32 * 1.000_002;
|
||
slab(&mut p, &mut i, &mut kinds, -3.0, 3.0, 0.0, 4.0, rim, SurfaceKind::Floor);
|
||
slab(&mut p, &mut i, &mut kinds, -3.0, 3.0, -4.0, 0.0, 0.0, SurfaceKind::Hazard);
|
||
slab(&mut p, &mut i, &mut kinds, -3.0, 3.0, -8.0, -4.0, rim, SurfaceKind::Floor);
|
||
slab(&mut p, &mut i, &mut kinds, -3.0, 3.0, -8.0, 4.0, 4.0, SurfaceKind::Floor);
|
||
let level = LevelCollision::from_positions(p, i).with_kinds(kinds);
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
// The graph knows the way out of the pool.
|
||
let in_pool: Vec<u32> = (0..nav.len() as u32)
|
||
.filter(|c| nav.cell(*c).unwrap().kind == SurfaceKind::Hazard)
|
||
.collect();
|
||
assert!(!in_pool.is_empty(), "the pool is in the grid");
|
||
let escapes = in_pool
|
||
.iter()
|
||
.filter(|c| {
|
||
nav.edges(**c)
|
||
.any(|(b, _)| nav.cell(b).unwrap().kind != SurfaceKind::Hazard)
|
||
})
|
||
.count();
|
||
assert!(escapes > 0, "no cell of the pool has a way out of it");
|
||
// And the body climbs it: dropped in the middle of the pool, the
|
||
// tour is on clean ground inside twenty seconds.
|
||
let start = nav.cell(in_pool[in_pool.len() / 2]).unwrap().pos;
|
||
let mut w = LevelWalker::new(start, 0.0, cfg, 6);
|
||
let mut escaped = false;
|
||
for _ in 0..1200 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
escaped |= w.feet().y > 0.3;
|
||
}
|
||
assert!(escaped, "still in the swamp at {:?}", w.feet());
|
||
}
|
||
|
||
#[test]
|
||
fn an_external_route_is_followed_verbatim() {
|
||
// The player_nav seam: the planner owns WHERE, this module owns HOW.
|
||
let cfg = cfg();
|
||
let level = two_floors();
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
let start = vec3f(0.0, 0.0, 5.0);
|
||
let from = nav.cell_at(start).expect("a cell under the start");
|
||
let goal = (0..nav.len() as u32)
|
||
.find(|i| {
|
||
let c = nav.cell(*i).unwrap();
|
||
(c.pos.y - 1.5).abs() < 1e-3 && c.pos.z < -9.0
|
||
})
|
||
.expect("an upstairs cell");
|
||
let (mut dist, mut parent) = (Vec::new(), Vec::new());
|
||
nav.flood(from, &std::collections::HashSet::new(), &mut dist, &mut parent);
|
||
let route = nav.path_to(goal, &parent);
|
||
assert!(route.len() > 4, "a real route: {}", route.len());
|
||
|
||
let mut w = LevelWalker::new(start, 0.0, cfg, 8);
|
||
w.set_external_planner(true);
|
||
assert!(w.has_external_planner());
|
||
w.set_route(route.clone());
|
||
for _ in 0..7200 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
if w.route().is_empty() {
|
||
break;
|
||
}
|
||
}
|
||
assert!(w.route().is_empty(), "the route was walked to the end");
|
||
let end = nav.cell(goal).unwrap().pos;
|
||
let (dx, dz) = (w.feet().x - end.x, w.feet().z - end.z);
|
||
assert!((dx * dx + dz * dz).sqrt() < 1.0, "arrived at the goal: {:?}", w.feet());
|
||
// And the tour engine did NOT invent goals of its own on the way.
|
||
let stats = w.nav_stats();
|
||
assert_eq!(stats.replans, 0, "the built-in planner stayed out of it: {stats:?}");
|
||
assert_eq!(stats.cuts, 0, "and it did not cut anywhere");
|
||
// With the route done, the planner's heading is what the body holds.
|
||
w.set_target_yaw(Some(1.0));
|
||
for _ in 0..120 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
}
|
||
assert!((w.yaw() - 1.0).abs() < 0.15, "held the planner's heading: {}", w.yaw());
|
||
// Doors are the planner's to ask for in this mode.
|
||
w.request_door(Some(3));
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
assert_eq!(w.wanted_door(), Some(3));
|
||
w.set_door_open(3, true);
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
assert_eq!(w.wanted_door(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn orthogonal_and_diagonal_steps_agree_on_open_ground() {
|
||
// Parity check for the planner: on clean open floor a diagonal step
|
||
// is as good as an orthogonal one. (Against a rim CORNER it is not —
|
||
// the landing clearance refuses it — which is why the planner should
|
||
// prefer orthogonal exits out of a sunken area.)
|
||
let cfg = cfg();
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
floor(&mut p, &mut i, -4.0, 4.0, -4.0, 4.0, 0.0);
|
||
floor(&mut p, &mut i, -4.0, 4.0, -4.0, 4.0, 3.0);
|
||
let level = LevelCollision::from_positions(p, i);
|
||
let w = LevelWalker::new(vec3f(0.0, 0.0, 0.0), 0.0, cfg, 1);
|
||
let step = cfg.speed / 60.0;
|
||
for (dx, dz) in [(1.0, 0.0), (0.0, 1.0), (0.707, 0.707), (-0.707, 0.707)] {
|
||
let r = w.step_refusal(&level, vec3f(dx * step, 0.0, dz * step));
|
||
assert!(matches!(r, StepRefusal::Ok { .. }), "({dx},{dz}) refused: {r:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_map_cut_into_rooms_reports_its_pieces() {
|
||
let cfg = cfg();
|
||
let nav = NavGrid::build(&two_sealed_rooms(), &cfg);
|
||
let sizes = nav.component_sizes();
|
||
assert!(sizes.len() >= 2, "two sealed rooms are two pieces: {sizes:?}");
|
||
assert!(sizes[0] > 20 && sizes[1] > 20, "both rooms have room: {sizes:?}");
|
||
let start = nav.best_start().expect("a start");
|
||
let here = nav.component_of(start).unwrap();
|
||
// And there is somewhere else to go.
|
||
let other = nav.next_region(start, &vec![0.0; nav.len()]).expect("another region");
|
||
assert_ne!(nav.component_of(other), Some(here), "the cut lands in a DIFFERENT room");
|
||
}
|
||
|
||
#[test]
|
||
fn a_seen_room_is_left_for_one_it_has_not_seen() {
|
||
// The tour must not spend the night in the room it started in just
|
||
// because the rest of the map is behind a wall it cannot open.
|
||
let cfg = cfg();
|
||
let level = two_sealed_rooms();
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
let start = nav.cell(nav.best_start().unwrap()).unwrap().pos;
|
||
let mut w = LevelWalker::new(start, 0.0, cfg, 12);
|
||
let first_room = nav.component_of(nav.cell_at(start).unwrap()).unwrap();
|
||
let (mut cut_to_other, mut flashes) = (false, 0);
|
||
for _ in 0..18_000 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
if w.take_flash() {
|
||
flashes += 1;
|
||
}
|
||
if let Some(c) = nav.cell_at(w.feet()) {
|
||
cut_to_other |= nav.component_of(c) != Some(first_room);
|
||
}
|
||
}
|
||
let stats = w.nav_stats();
|
||
assert!(cut_to_other, "never left the first room: {stats:?}");
|
||
assert!(stats.cuts >= 1, "the cut is counted: {stats:?}");
|
||
assert_eq!(flashes as u64, stats.cuts, "every cut flashes exactly once");
|
||
}
|
||
|
||
#[test]
|
||
fn a_teleport_pad_cuts_to_its_destination() {
|
||
let cfg = cfg();
|
||
let level = two_sealed_rooms();
|
||
let mut nav = NavGrid::build(&level, &cfg);
|
||
// A pad in the near room, landing in the far one.
|
||
nav.mark_teleports(&[(
|
||
vec3f(-1.0, 0.0, 0.5),
|
||
vec3f(1.0, 1.0, 1.5),
|
||
vec3f(0.0, 0.0, -17.0),
|
||
std::f32::consts::PI,
|
||
)]);
|
||
let pad = (0..nav.len() as u32)
|
||
.find(|c| nav.cell(*c).unwrap().teleport == Some(0))
|
||
.expect("the pad marked its cells");
|
||
assert!(nav.teleport_target(0).is_some());
|
||
// The planner can now route from the near room into the far one:
|
||
// the pad is a ONE-WAY edge, exactly like Doom's.
|
||
let (mut dist, mut parent) = (Vec::new(), Vec::new());
|
||
nav.flood(pad, &std::collections::HashSet::new(), &mut dist, &mut parent);
|
||
let far = (0..nav.len())
|
||
.filter(|i| nav.cell(*i as u32).unwrap().pos.z < -14.0)
|
||
.filter(|i| dist[*i] != NAV_UNREACHED)
|
||
.count();
|
||
assert!(far > 20, "the far room is reachable through the pad: {far} cells");
|
||
// Standing on the pad cuts to the far room, facing the given way.
|
||
let mut w = LevelWalker::new(nav.cell(pad).unwrap().pos, 0.0, cfg, 2);
|
||
w.tick_in(1.0 / 60.0, &level, Some(&nav));
|
||
assert!(w.feet().z < -14.0, "teleported: {:?}", w.feet());
|
||
assert!((w.yaw() - std::f32::consts::PI).abs() < 1e-3, "arrives facing the anchor's way");
|
||
assert!(w.take_flash(), "a teleport flashes");
|
||
assert_eq!(w.cut_count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn nav_cells_stay_a_body_clear_of_the_walls() {
|
||
let cfg = cfg();
|
||
let level = corridor();
|
||
let nav = NavGrid::build(&level, &cfg);
|
||
assert!(!nav.is_empty(), "a corridor has walkable cells");
|
||
for c in 0..nav.len() as u32 {
|
||
let p = nav.cell(c).unwrap().pos;
|
||
assert!(
|
||
p.x > -1.0 + cfg.radius - 1e-3 && p.x < 1.0 - cfg.radius + 1e-3,
|
||
"cell inside a wall: {p:?}"
|
||
);
|
||
assert!((p.y - 0.0).abs() < 1e-3, "cells sit on the floor: {p:?}");
|
||
}
|
||
// Every cell is found again from its own position.
|
||
for c in 0..nav.len() as u32 {
|
||
assert_eq!(nav.cell_at(nav.cell(c).unwrap().pos), Some(c));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn z_up_sources_are_converted_at_build_time() {
|
||
// One triangle lying flat in a Z-up source (constant z) must become
|
||
// a floor (constant y) after conversion.
|
||
let verts: Vec<f32> = vec![
|
||
0.0, 0.0, 5.0, //
|
||
1.0, 0.0, 5.0, //
|
||
0.0, 1.0, 5.0, //
|
||
];
|
||
let level =
|
||
LevelCollision::from_packed(&verts, 3, &[0, 1, 2], UpAxis::Z).expect("built");
|
||
let (min, max) = level.bounds();
|
||
assert!((min.y - 5.0).abs() < 1e-6 && (max.y - 5.0).abs() < 1e-6, "{min:?} {max:?}");
|
||
let hit = level.floor_below(vec3f(0.2, 9.0, -0.2), 10.0).expect("floor under the probe");
|
||
assert!((hit.y - 5.0).abs() < 1e-4);
|
||
assert!(hit.flatness > 0.99, "converted triangle is flat: {hit:?}");
|
||
}
|
||
}
|
||
|
||
// player_nav: shared synthetic-level builders, so the player-behaviour
|
||
// planner's test suite builds its fixtures the same way this module's tests
|
||
// do (triangle soups through `LevelCollision::from_positions`). The `tests`
|
||
// module above keeps its private copies — it is frozen; new suites use these.
|
||
#[cfg(test)]
|
||
pub(crate) mod test_geometry {
|
||
use super::*;
|
||
|
||
/// Append a quad (two triangles) to a triangle soup.
|
||
pub fn quad(pos: &mut Vec<Vec3f>, idx: &mut Vec<u32>, a: Vec3f, b: Vec3f, c: Vec3f, d: Vec3f) {
|
||
let n = pos.len() as u32;
|
||
pos.extend_from_slice(&[a, b, c, d]);
|
||
idx.extend_from_slice(&[n, n + 1, n + 2, n, n + 2, n + 3]);
|
||
}
|
||
|
||
/// A horizontal slab top at `y` spanning the given x/z range.
|
||
pub fn floor(
|
||
pos: &mut Vec<Vec3f>,
|
||
idx: &mut Vec<u32>,
|
||
x0: f32,
|
||
x1: f32,
|
||
z0: f32,
|
||
z1: f32,
|
||
y: f32,
|
||
) {
|
||
quad(
|
||
pos,
|
||
idx,
|
||
vec3f(x0, y, z0),
|
||
vec3f(x1, y, z0),
|
||
vec3f(x1, y, z1),
|
||
vec3f(x0, y, z1),
|
||
);
|
||
}
|
||
|
||
/// A vertical wall spanning x0..x1 at `z`, from y0 to y1.
|
||
pub fn wall_z(
|
||
pos: &mut Vec<Vec3f>,
|
||
idx: &mut Vec<u32>,
|
||
x0: f32,
|
||
x1: f32,
|
||
z: f32,
|
||
y0: f32,
|
||
y1: f32,
|
||
) {
|
||
quad(
|
||
pos,
|
||
idx,
|
||
vec3f(x0, y0, z),
|
||
vec3f(x1, y0, z),
|
||
vec3f(x1, y1, z),
|
||
vec3f(x0, y1, z),
|
||
);
|
||
}
|
||
|
||
/// A vertical wall spanning z0..z1 at `x`, from y0 to y1.
|
||
pub fn wall_x(
|
||
pos: &mut Vec<Vec3f>,
|
||
idx: &mut Vec<u32>,
|
||
z0: f32,
|
||
z1: f32,
|
||
x: f32,
|
||
y0: f32,
|
||
y1: f32,
|
||
) {
|
||
quad(
|
||
pos,
|
||
idx,
|
||
vec3f(x, y0, z0),
|
||
vec3f(x, y0, z1),
|
||
vec3f(x, y1, z1),
|
||
vec3f(x, y1, z0),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The map's units, and a route a body can actually walk
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// What went wrong on Quake 1 maps, pinned.
|
||
///
|
||
/// Three separate faults, all of which read to a viewer as one thing — a
|
||
/// walker that spins, stops and gets stuck on corners:
|
||
///
|
||
/// 1. the body was in the PRESET's units, not the map's (a Quake 1 level is
|
||
/// published at 1/32, twice a Doom level's metres per map unit);
|
||
/// 2. the nav lattice was a fixed 0.5 m instead of one body wide, so the
|
||
/// graph was finer than the legs walking it;
|
||
/// 3. the walker stood still for any heading change over 34°, and walked
|
||
/// at a speed its own turn rate could not steer — so it orbited close
|
||
/// waypoints instead of reaching them.
|
||
#[cfg(test)]
|
||
mod walk_in_the_maps_units {
|
||
use super::test_geometry::{floor, wall_x, wall_z};
|
||
use super::*;
|
||
|
||
/// A roofed room `span` across, floor at 0.
|
||
fn room(span: f32, height: f32) -> LevelCollision {
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
let h = span * 0.5;
|
||
floor(&mut p, &mut i, -h, h, -h, h, 0.0);
|
||
floor(&mut p, &mut i, -h, h, -h, h, height);
|
||
wall_x(&mut p, &mut i, -h, h, -h, 0.0, height);
|
||
wall_x(&mut p, &mut i, -h, h, h, 0.0, height);
|
||
wall_z(&mut p, &mut i, -h, h, -h, 0.0, height);
|
||
wall_z(&mut p, &mut i, -h, h, h, 0.0, height);
|
||
LevelCollision::from_positions(p, i)
|
||
}
|
||
|
||
#[test]
|
||
fn a_declared_step_puts_the_whole_body_in_the_maps_units() {
|
||
let preset = WalkerConfig::for_style(BobStyle::Quake);
|
||
// Quake 1 publishes at 1/32: its 18-unit step is 0.5625, twice the
|
||
// 18-at-1/64 the preset carries.
|
||
let quake1 = preset.with_declared(Some(0.5625), Some(1.4375));
|
||
let k = 0.5625 / preset.step_up;
|
||
assert!((k - 2.0).abs() < 0.01, "the Quake 1 scale is 2x: {k}");
|
||
assert!((quake1.step_up - 0.5625).abs() < 1e-4, "{}", quake1.step_up);
|
||
assert!((quake1.radius - preset.radius * k).abs() < 1e-4, "{}", quake1.radius);
|
||
assert!((quake1.height - preset.height * k).abs() < 1e-4, "{}", quake1.height);
|
||
assert!((quake1.speed - preset.speed * k).abs() < 1e-3, "{}", quake1.speed);
|
||
// Gravity is a length per second squared: it scales once too, or a
|
||
// body in a double-scale world falls in slow motion.
|
||
assert!((quake1.gravity - preset.gravity * k).abs() < 1e-3, "{}", quake1.gravity);
|
||
assert!((quake1.fall_limit - preset.fall_limit * k).abs() < 1e-3, "{}", quake1.fall_limit);
|
||
// The eye is declared outright, not scaled: it is what the map says.
|
||
assert!((quake1.eye_height - 1.4375).abs() < 1e-4, "{}", quake1.eye_height);
|
||
|
||
// A map at the preset's own scale changes nothing but the eye.
|
||
let doom = WalkerConfig::for_style(BobStyle::Doom).with_declared(Some(0.375), Some(0.6406));
|
||
let base = WalkerConfig::for_style(BobStyle::Doom);
|
||
assert!((doom.step_up - base.step_up).abs() < 1e-6, "{}", doom.step_up);
|
||
assert!((doom.radius - base.radius).abs() < 1e-6, "{}", doom.radius);
|
||
assert!((doom.speed - base.speed).abs() < 1e-6, "{}", doom.speed);
|
||
|
||
// Declaring nothing (Build states no step height) keeps the preset,
|
||
// and a nonsense declaration is refused rather than believed.
|
||
let none = WalkerConfig::for_style(BobStyle::Duke).with_declared(None, None);
|
||
assert_eq!(none.step_up, WalkerConfig::for_style(BobStyle::Duke).step_up);
|
||
let junk = WalkerConfig::for_style(BobStyle::Doom).with_declared(Some(400.0), None);
|
||
assert_eq!(junk.step_up, base.step_up, "a 400 m step is a bad sidecar, not a scale");
|
||
let nan = WalkerConfig::for_style(BobStyle::Doom).with_declared(Some(f32::NAN), None);
|
||
assert_eq!(nan.step_up, base.step_up);
|
||
}
|
||
|
||
#[test]
|
||
fn the_nav_lattice_is_one_body_wide_whatever_the_map_scale() {
|
||
let level = room(24.0, 4.0);
|
||
let doom = WalkerConfig::for_style(BobStyle::Doom);
|
||
let quake1 = WalkerConfig::for_style(BobStyle::Quake).with_declared(Some(0.5625), None);
|
||
let small = NavGrid::build(&level, &doom);
|
||
let big = NavGrid::build(&level, &quake1);
|
||
assert!((small.cell_size() - doom.radius * 2.0).abs() < 1e-4, "{}", small.cell_size());
|
||
assert!((big.cell_size() - quake1.radius * 2.0).abs() < 1e-4, "{}", big.cell_size());
|
||
assert!(
|
||
big.cell_size() > small.cell_size() * 1.9,
|
||
"a body twice as wide gets a lattice twice as coarse: {} vs {}",
|
||
big.cell_size(),
|
||
small.cell_size()
|
||
);
|
||
}
|
||
|
||
/// Put a walker in an open room on an external route to one cell, and
|
||
/// report how long it took to get there (in ticks) and how far it
|
||
/// wandered on the way.
|
||
fn drive_to(cfg: WalkerConfig, target: Vec3f, ticks: usize) -> (Option<usize>, f32) {
|
||
let level = room(32.0, 6.0);
|
||
let grid = NavGrid::build(&level, &cfg);
|
||
let start = vec3f(0.0, 0.0, 0.0);
|
||
let goal = grid.cell_at(target).expect("target is walkable");
|
||
let goal_pos = grid.cell(goal).expect("cell").pos;
|
||
// Facing -Z: the target below is off to the side, which is what a
|
||
// route corner looks like from the body's point of view.
|
||
let mut w = LevelWalker::new(start, 0.0, cfg, 3);
|
||
w.set_external_planner(true);
|
||
w.set_route(vec![goal]);
|
||
let mut travelled = 0.0;
|
||
let mut prev = start;
|
||
for t in 0..ticks {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&grid));
|
||
let feet = w.feet();
|
||
travelled += ((feet.x - prev.x).powi(2) + (feet.z - prev.z).powi(2)).sqrt();
|
||
prev = feet;
|
||
let gap =
|
||
((feet.x - goal_pos.x).powi(2) + (feet.z - goal_pos.z).powi(2)).sqrt();
|
||
if gap < grid.cell_size() * 0.6 || w.route().is_empty() {
|
||
return (Some(t), travelled);
|
||
}
|
||
}
|
||
(None, travelled)
|
||
}
|
||
|
||
#[test]
|
||
fn a_waypoint_inside_the_turning_circle_is_reached_and_not_orbited() {
|
||
// The Quake 1 body: 3.8 m/s over 1.6 rad/s is a 2.4 m turning
|
||
// circle, and its nav cells are a metre apart. A waypoint 1.5 m to
|
||
// the side sits well inside that circle.
|
||
let cfg = WalkerConfig::for_style(BobStyle::Quake).with_declared(Some(0.5625), None);
|
||
let radius = cfg.turn_radius();
|
||
assert!(radius > 2.0, "the Quake 1 tour has a wide turning circle: {radius}");
|
||
let target = vec3f(1.5, 0.0, 0.0);
|
||
let (arrived, travelled) = drive_to(cfg, target, 60 * 8);
|
||
let arrived = arrived.expect("the body must reach a waypoint inside its turning circle");
|
||
// Orbiting shows up as distance without arrival: the straight line
|
||
// is 1.5 m, and a body that circles covers many times that.
|
||
assert!(
|
||
travelled < 6.0,
|
||
"walked {travelled:.1} m to a point 1.5 m away — that is an orbit, not a path"
|
||
);
|
||
assert!(arrived < 60 * 5, "took {arrived} ticks to walk 1.5 m");
|
||
}
|
||
|
||
#[test]
|
||
fn the_body_covers_ground_while_it_turns() {
|
||
// A route corner behind the shoulder: the heading error starts well
|
||
// over the "facing the target" gate. The body used to stand
|
||
// perfectly still until it had spun to within 34° — which is the
|
||
// stop-spin-go stutter, and (being no progress) it also tripped the
|
||
// planner's watchdog, whose dropped legs then came back cell by
|
||
// cell, so every one of THOSE turned 45° too.
|
||
let cfg = WalkerConfig::for_style(BobStyle::Doom);
|
||
let level = room(32.0, 6.0);
|
||
let grid = NavGrid::build(&level, &cfg);
|
||
// Straight out to the side: yaw 0 looks down -Z, so +X is 90° off.
|
||
let goal = grid.cell_at(vec3f(6.0, 0.0, 0.0)).expect("walkable");
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 0.0), 0.0, cfg, 3);
|
||
w.set_external_planner(true);
|
||
w.set_route(vec![goal]);
|
||
let mut moved_while_turning = 0.0;
|
||
for _ in 0..18 {
|
||
let before = w.feet();
|
||
w.tick_in(1.0 / 60.0, &level, Some(&grid));
|
||
// Still turning: nowhere near the target heading yet.
|
||
assert!(
|
||
w.yaw().abs() < std::f32::consts::FRAC_PI_2 - 0.2,
|
||
"the turn should not be finished in 0.3 s"
|
||
);
|
||
let feet = w.feet();
|
||
moved_while_turning +=
|
||
((feet.x - before.x).powi(2) + (feet.z - before.z).powi(2)).sqrt();
|
||
}
|
||
assert!(
|
||
moved_while_turning > 0.02,
|
||
"the body stood still for the whole turn ({moved_while_turning:.3} m)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_walker_never_walks_faster_than_its_turn_can_steer() {
|
||
// The invariant behind both of the above: the circle a body traces
|
||
// (speed / turn rate) must fit inside the distance to what it is
|
||
// steering at.
|
||
let cfg = WalkerConfig::for_style(BobStyle::Quake).with_declared(Some(0.5625), None);
|
||
let level = room(32.0, 6.0);
|
||
let grid = NavGrid::build(&level, &cfg);
|
||
let goal = grid.cell_at(vec3f(1.2, 0.0, 0.6)).expect("walkable");
|
||
let goal_pos = grid.cell(goal).expect("cell").pos;
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 0.0), 0.0, cfg, 3);
|
||
w.set_external_planner(true);
|
||
w.set_route(vec![goal]);
|
||
for _ in 0..240 {
|
||
let before = w.feet();
|
||
w.tick_in(1.0 / 60.0, &level, Some(&grid));
|
||
if w.route().is_empty() {
|
||
return; // arrived, which is the point
|
||
}
|
||
let feet = w.feet();
|
||
let speed =
|
||
((feet.x - before.x).powi(2) + (feet.z - before.z).powi(2)).sqrt() * 60.0;
|
||
let gap =
|
||
((before.x - goal_pos.x).powi(2) + (before.z - goal_pos.z).powi(2)).sqrt();
|
||
// The cap is on the speed the body WANTS; the actual one eases
|
||
// down over `SPEED_EASE_SECS`, and the gap closes while it does.
|
||
let lag = speed * SPEED_EASE_SECS;
|
||
assert!(
|
||
speed <= cfg.turn_rate * (gap + lag) + 0.05,
|
||
"walked {speed:.2} m/s at a waypoint {gap:.2} m away — a circle of \
|
||
{:.2} m round a point it can never reach",
|
||
speed / cfg.turn_rate
|
||
);
|
||
}
|
||
panic!("never reached a waypoint 1.3 m away in four seconds");
|
||
}
|
||
|
||
#[test]
|
||
fn a_fresh_route_forgets_the_old_ones_slide() {
|
||
// The "try the other way round the obstruction" offset is armed by
|
||
// the stuck watchdog and cleared by the next plan — except that the
|
||
// clearing lives in the BUILT-IN tour's replan, which an external
|
||
// planner never runs. So on a `player_nav` tour the first give-up
|
||
// left the body steering 34° off every bearing it was handed, for
|
||
// the rest of the level, and a body that crabs converges on nothing.
|
||
let cfg = WalkerConfig::for_style(BobStyle::Doom);
|
||
let level = room(32.0, 6.0);
|
||
let grid = NavGrid::build(&level, &cfg);
|
||
let goal = grid.cell_at(vec3f(0.0, 0.0, -6.0)).expect("walkable");
|
||
let mut w = LevelWalker::new(vec3f(0.0, 0.0, 0.0), 0.0, cfg, 3);
|
||
w.set_external_planner(true);
|
||
w.set_route(vec![goal]);
|
||
w.tick_in(1.0 / 60.0, &level, Some(&grid));
|
||
// Arm it the way the watchdog does.
|
||
w.nav.as_mut().expect("nav state").slide = -0.6;
|
||
w.set_route(vec![goal]);
|
||
assert_eq!(
|
||
w.nav.as_ref().expect("nav state").slide,
|
||
0.0,
|
||
"a fresh route must not inherit the last one's slide offset"
|
||
);
|
||
// And the body converges on it.
|
||
let goal_pos = grid.cell(goal).expect("cell").pos;
|
||
let mut best = f32::MAX;
|
||
for _ in 0..60 * 8 {
|
||
w.tick_in(1.0 / 60.0, &level, Some(&grid));
|
||
let feet = w.feet();
|
||
best = best
|
||
.min(((feet.x - goal_pos.x).powi(2) + (feet.z - goal_pos.z).powi(2)).sqrt());
|
||
}
|
||
assert!(
|
||
best < grid.cell_size(),
|
||
"a crabbing body never converges: closest approach {best:.2} m"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod level_vehicle_tests {
|
||
use super::*;
|
||
|
||
fn quad(p: &mut Vec<Vec3f>, i: &mut Vec<u32>, a: Vec3f, b: Vec3f, c: Vec3f, d: Vec3f) {
|
||
let n = p.len() as u32;
|
||
p.extend_from_slice(&[a, b, c, d]);
|
||
i.extend_from_slice(&[n, n + 1, n + 2, n, n + 2, n + 3]);
|
||
}
|
||
|
||
fn floor(p: &mut Vec<Vec3f>, i: &mut Vec<u32>, x0: f32, x1: f32, z0: f32, z1: f32, y: f32) {
|
||
quad(p, i, vec3f(x0, y, z0), vec3f(x1, y, z0), vec3f(x1, y, z1), vec3f(x0, y, z1));
|
||
}
|
||
|
||
/// Wall in the x/y plane at `z`, spanning x0..x1, y0..y1.
|
||
fn wall_z(p: &mut Vec<Vec3f>, i: &mut Vec<u32>, x0: f32, x1: f32, z: f32, y0: f32, y1: f32) {
|
||
quad(p, i, vec3f(x0, y0, z), vec3f(x1, y0, z), vec3f(x1, y1, z), vec3f(x0, y1, z));
|
||
}
|
||
|
||
/// A room 8×8, floor 0, ceiling `roof`, with a doorway in the z=0 wall:
|
||
/// opening x -1..1, lintel from `door_h` up to the ceiling. Room extends
|
||
/// z 0..8; open ground z<0.
|
||
fn doorway_room(roof: f32, door_h: f32) -> LevelCollision {
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
floor(&mut p, &mut i, -8.0, 8.0, -8.0, 8.0, 0.0);
|
||
floor(&mut p, &mut i, -4.0, 4.0, 0.0, 8.0, roof);
|
||
// The z=0 wall around the opening.
|
||
wall_z(&mut p, &mut i, -4.0, -1.0, 0.0, 0.0, roof);
|
||
wall_z(&mut p, &mut i, 1.0, 4.0, 0.0, 0.0, roof);
|
||
wall_z(&mut p, &mut i, -1.0, 1.0, 0.0, door_h, roof); // lintel
|
||
// Enclosing walls: sides at x ±4, far wall at z 8.
|
||
quad(
|
||
&mut p,
|
||
&mut i,
|
||
vec3f(-4.0, 0.0, 0.0),
|
||
vec3f(-4.0, 0.0, 8.0),
|
||
vec3f(-4.0, roof, 8.0),
|
||
vec3f(-4.0, roof, 0.0),
|
||
);
|
||
quad(
|
||
&mut p,
|
||
&mut i,
|
||
vec3f(4.0, 0.0, 0.0),
|
||
vec3f(4.0, 0.0, 8.0),
|
||
vec3f(4.0, roof, 8.0),
|
||
vec3f(4.0, roof, 0.0),
|
||
);
|
||
wall_z(&mut p, &mut i, -4.0, 4.0, 8.0, 0.0, roof);
|
||
LevelCollision::from_positions(p, i)
|
||
}
|
||
|
||
#[test]
|
||
fn ray_hit_reports_distance_and_a_normal_facing_the_ray() {
|
||
let level = doorway_room(3.0, 2.0);
|
||
let (t, n) = level
|
||
.ray_hit(vec3f(2.0, 1.0, 2.0), vec3f(0.0, 0.0, -1.0), 10.0)
|
||
.expect("the z=0 wall is 2 m away");
|
||
assert!((t - 2.0).abs() < 1.0e-3, "distance {t}");
|
||
assert!(n.z > 0.99, "normal {n:?} must face back along the ray");
|
||
// Up into the ceiling.
|
||
let (t, n) = level
|
||
.ray_hit(vec3f(0.0, 1.0, 4.0), vec3f(0.0, 1.0, 0.0), 10.0)
|
||
.expect("ceiling above");
|
||
assert!((t - 2.0).abs() < 1.0e-3, "ceiling distance {t}");
|
||
assert!(n.y < -0.99, "ceiling normal {n:?} pushes down");
|
||
}
|
||
|
||
#[test]
|
||
fn ground_under_stands_a_body_on_the_floor_not_the_ceiling() {
|
||
let level = doorway_room(3.0, 2.0);
|
||
// Inside the room, spawned mid-air: the room's floor, though the
|
||
// ceiling is a nearer surface for a probe from the sky.
|
||
assert_eq!(level.ground_under(0.0, 4.0, 1.2, 1.0), Some(0.0));
|
||
// Spawned above the roof: the roof is standable from up there.
|
||
assert_eq!(level.ground_under(0.0, 4.0, 3.5, 1.0), Some(3.0));
|
||
}
|
||
|
||
#[test]
|
||
fn ground_under_lifts_a_body_buried_under_a_raised_floor() {
|
||
// Two slabs: ground at 0 (z<0 half) — modelled by a low slab — and a
|
||
// raised floor at 1.5 with headroom above.
|
||
let (mut p, mut i) = (Vec::new(), Vec::new());
|
||
floor(&mut p, &mut i, -4.0, 4.0, -4.0, 4.0, 1.5);
|
||
floor(&mut p, &mut i, -4.0, 4.0, -4.0, 4.0, 4.0); // ceiling
|
||
let level = LevelCollision::from_positions(p, i);
|
||
// A car spawned at y 0.6 under the 1.5 floor belongs ON that floor.
|
||
assert_eq!(level.ground_under(0.0, 0.0, 0.6, 1.0), Some(1.5));
|
||
}
|
||
|
||
#[test]
|
||
fn room_at_measures_headroom_and_span() {
|
||
let level = doorway_room(3.0, 2.0);
|
||
let (headroom, span) = level.room_at(vec3f(0.0, 0.5, 4.0)).expect("indoors");
|
||
assert!((headroom - 3.0).abs() < 0.05, "headroom {headroom}");
|
||
// Walls at x ±4 bound the narrowest axis (the z probe escapes
|
||
// through the doorway to open ground).
|
||
assert!((span - 8.0).abs() < 0.5, "span {span} should be the 8 m width");
|
||
// Outside: no ceiling, no measurement.
|
||
assert!(level.room_at(vec3f(6.0, 0.5, 4.0)).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_body_taller_than_the_door_cannot_pass_a_shorter_one_can() {
|
||
let level = doorway_room(3.0, 2.0);
|
||
// A footprint sweep straight through the doorway, x -0.6..0.6.
|
||
let moves: Vec<(Vec3f, Vec3f)> = [-0.6f32, 0.0, 0.6]
|
||
.iter()
|
||
.map(|&x| (vec3f(x, 0.0, -2.0), vec3f(x, 0.0, 2.0)))
|
||
.collect();
|
||
// 1.4 m tall body: knee, waist, roof rays all under the 2 m lintel.
|
||
assert!(
|
||
!level.moves_blocked(&moves, 0.0, &[0.4, 0.8, 1.3]),
|
||
"a 1.4 m body must fit a 2 m doorway"
|
||
);
|
||
// 2.4 m tall body: the roof ray meets the lintel.
|
||
assert!(
|
||
level.moves_blocked(&moves, 0.0, &[0.4, 1.2, 2.3]),
|
||
"a 2.4 m body must be stopped by the 2 m lintel"
|
||
);
|
||
// Off to the side, any height meets the wall.
|
||
let side = [(vec3f(2.5, 0.0, -2.0), vec3f(2.5, 0.0, 2.0))];
|
||
assert!(level.moves_blocked(&side, 0.0, &[0.4, 1.2]), "the wall blocks");
|
||
}
|
||
}
|