diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index c8600d7..9ce2fc7 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -10,7 +10,7 @@ use sdl3::keyboard::Keycode; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::Instant; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; /// Аргументы команды `map`. #[derive(Debug, clap::Args)] @@ -139,7 +139,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let world = std::rc::Rc::new(std::cell::RefCell::new(engine::build_world(&bsp, &vfs)?)); info!(elapsed_ms = started.elapsed().as_millis(), "мир собран"); - let collision = { + let mut collision = { let mut collision = engine::Collision::from_bsp(&bsp)?; collision.add_displacement_tris(&world.borrow().disp_tris); info!( @@ -357,6 +357,27 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { w.step_physics(dt); w.recover_fallen_props(-8192.0); } + // Габариты пропов уходят в коллизию игрока: без этого он ходит по + // карте, но проходит сквозь всё, что лежит на ней. + { + let w = world.borrow(); + let boxes = w + .props + .iter() + .filter_map(|prop| { + let mesh = w.prop_meshes.get(prop.mesh)?; + let half = mesh.bounds_half_extents; + if half.length_squared() < 1.0 { + return None; + } + Some(math::Aabb::new( + prop.origin + math::Vec3::new(-half.x, -half.y, -half.z), + prop.origin + half, + )) + }) + .collect(); + collision.set_boxes(boxes); + } let mut move_forward = 0.0; let mut move_right = 0.0; let mut move_up = 0.0; @@ -485,6 +506,15 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { warn!("кадр не отрисован: {err:#}"); } + if frames_drawn % 30 == 0 { + debug!( + frames_drawn, + z = player.position.z, + vz = player.velocity.z, + on_ground = player.on_ground, + "диагностика: игрок стоит" + ); + } frames_drawn += 1; if let Some(max_frames) = args.frames { if frames_drawn >= max_frames { diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index 2bc073d..664fa2f 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -24,10 +24,19 @@ pub struct Brush { #[derive(Debug, Default)] pub struct Collision { pub brushes: Vec, + /// Габариты подвижных тел (пропы, предметы) — меняются каждый кадр. + /// + /// Без них игрок **проходит сквозь** любую динамическую сущность: встать + /// на ящик — значит шагнуть в пустоту и провалиться. Это отдельный мир + /// коллизий от брашей карты, и он обязан быть частью `Collision`, а не + /// жить только в Rapier: игрок ходит своей кодовой базой. + pub boxes: Vec, /// Треугольники displacement'ов. tris: Vec<[Vec3; 3]>, /// Сетка по X/Y: ячейка → индексы треугольников. grid: HashMap<(i32, i32), Vec>, + /// Сетка по X/Y: ячейка → индексы габаритов тел. + box_grid: HashMap<(i32, i32), Vec>, } /// Сторона ячейки пространственной сетки. @@ -170,6 +179,8 @@ impl Collision { "браши: отбор по CONTENTS_SOLID" ); Ok(Self { + boxes: Vec::new(), + box_grid: HashMap::new(), brushes, tris: Vec::new(), grid: HashMap::new(), @@ -177,6 +188,26 @@ impl Collision { } /// Добавляет треугольники displacement'ов в коллизию. + /// Обновляет габариты подвижных тел — вызывать каждый кадр. + /// + /// Сетка перестраивается целиком: тел мало (десятки), а пересборка + /// убирает риск «забыть обновить» — источник тихих провалов сквозь пол. + pub fn set_boxes(&mut self, boxes: Vec) { + self.boxes = boxes; + self.box_grid.clear(); + for (index, aabb) in self.boxes.iter().enumerate() { + let x0 = (aabb.min.x / GRID_CELL).floor() as i32; + let x1 = (aabb.max.x / GRID_CELL).floor() as i32; + let y0 = (aabb.min.y / GRID_CELL).floor() as i32; + let y1 = (aabb.max.y / GRID_CELL).floor() as i32; + for x in x0..=x1 { + for y in y0..=y1 { + self.box_grid.entry((x, y)).or_default().push(index as u32); + } + } + } + } + pub fn add_displacement_tris(&mut self, tris: &[[Vec3; 3]]) { let base = self.tris.len() as u32; self.tris.extend_from_slice(tris); @@ -220,6 +251,62 @@ impl Collision { } /// Минимальный сдвиг для выхода AABB из треугольника (SAT, 13 осей). + /// Индексы габаритов тел, пересекающихся или близких к AABB. + fn nearby_boxes(&self, aabb: &Aabb) -> Vec { + let mut out = Vec::new(); + let x0 = (aabb.min.x / GRID_CELL).floor() as i32; + let x1 = (aabb.max.x / GRID_CELL).floor() as i32; + let y0 = (aabb.min.y / GRID_CELL).floor() as i32; + let y1 = (aabb.max.y / GRID_CELL).floor() as i32; + for x in x0..=x1 { + for y in y0..=y1 { + if let Some(list) = self.box_grid.get(&(x, y)) { + out.extend_from_slice(list); + } + } + } + out.sort_unstable(); + out.dedup(); + out + } + + /// Выталкивание AABB из AABB: минимальная ось проникновения. + fn aabb_penetration(aabb: &Aabb, other: &Aabb) -> Option { + // Нет пересечения по всем трём осям — тело не мешает. + if aabb.min.x >= other.max.x + || aabb.max.x <= other.min.x + || aabb.min.y >= other.max.y + || aabb.max.y <= other.min.y + || aabb.min.z >= other.max.z + || aabb.max.z <= other.min.z + { + return None; + } + // Выход по оси наименьшего проникновения: так тело выталкивается + // в наименьшее число раз и не «застревает» в углу. + let push_x = aabb.max.x - other.min.x; + let push_x2 = other.max.x - aabb.min.x; + let push_y = aabb.max.y - other.min.y; + let push_y2 = other.max.y - aabb.min.y; + let push_z = aabb.max.z - other.min.z; + let push_z2 = other.max.z - aabb.min.z; + let mut best = push_x; + let mut normal = Vec3::new(-1.0, 0.0, 0.0); + for (value, candidate) in [ + (push_x2, Vec3::new(1.0, 0.0, 0.0)), + (push_y, Vec3::new(0.0, -1.0, 0.0)), + (push_y2, Vec3::new(0.0, 1.0, 0.0)), + (push_z, Vec3::new(0.0, 0.0, -1.0)), + (push_z2, Vec3::new(0.0, 0.0, 1.0)), + ] { + if value < best { + best = value; + normal = candidate; + } + } + Some(normal * (best + 0.1)) + } + fn tri_penetration(aabb: &Aabb, tri: &[Vec3; 3]) -> Option { let center = aabb.center(); let h = aabb.size() * 0.5; @@ -354,6 +441,20 @@ impl Collision { hit = true; } } + for &index in &self.nearby_boxes(&aabb) { + let box_aabb = self.boxes[index as usize]; + if let Some(push) = Self::aabb_penetration(&aabb, &box_aabb) { + let push = push * 1.001; + aabb.min += push; + aabb.max += push; + let len = push.length(); + if len > 1e-6 && push.z / len > 0.6 { + on_ground = true; + } + Self::push_contact(&mut contacts, push); + hit = true; + } + } for &index in &candidates { let tri = &self.tris[index as usize]; if let Some(push) = Self::tri_penetration(&aabb, tri) { @@ -1073,4 +1174,95 @@ mod tests { player.velocity ); } + /// Игрок обязан стоять на пропе, а не проваливаться сквозь него. + /// + /// Пропы живут в мире Rapier, а игрок ходит своей кодовой базой. Пока + /// габариты тел не передавались в `Collision`, встать на ящик означало + /// шагнуть в пустоту — это и есть жалоба «проваливаюсь при хождении». + #[test] + fn player_stands_on_a_dynamic_body() { + let mut collision = Collision { + brushes: Vec::new(), + tris: Vec::new(), + grid: HashMap::new(), + boxes: Vec::new(), + box_grid: HashMap::new(), + }; + // Плита на высоте 100: игрок должен на ней остановиться. + let slab = Aabb::new( + Vec3::new(-256.0, -256.0, 80.0), + Vec3::new(256.0, 256.0, 100.0), + ); + collision.set_boxes(vec![slab]); + + let mut player = Player::new(Vec3::new(0.0, 0.0, 140.0)); + for _ in 0..240 { + player.update(&collision, 0.0, 0.0, false, false, 0.0, 1.0 / 60.0); + } + assert!( + player.on_ground, + "игрок должен стоять на теле, а не проваливаться: z={}", + player.position.z + ); + assert!( + (player.position.z - 100.0).abs() < 2.0, + "ступни должны стоять на верхней грани тела, z={}", + player.position.z + ); + } + + /// Без габаритов тел игрок проходит насквозь — это исходное поведение, + /// которое тест выше фиксирует как отказ. + #[test] + fn without_boxes_the_player_falls_through() { + let collision = Collision { + brushes: Vec::new(), + tris: Vec::new(), + grid: HashMap::new(), + boxes: Vec::new(), + box_grid: HashMap::new(), + }; + let mut player = Player::new(Vec3::new(0.0, 0.0, 140.0)); + for _ in 0..240 { + player.update(&collision, 0.0, 0.0, false, false, 0.0, 1.0 / 60.0); + } + assert!( + !player.on_ground, + "без габаритов тел проваливание — ожидаемое поведение" + ); + } + + /// Сквозь стену игрок пройти не должен: габариты тел не должны + /// «протекать» и насквозь. + #[test] + fn boxes_block_horizontal_movement() { + let mut collision = Collision { + brushes: Vec::new(), + tris: Vec::new(), + grid: HashMap::new(), + boxes: Vec::new(), + box_grid: HashMap::new(), + }; + // Стена поперёк движения. + let wall = Aabb::new(Vec3::new(60.0, -256.0, 0.0), Vec3::new(100.0, 256.0, 300.0)); + collision.set_boxes(vec![wall]); + + let mut player = Player::new(Vec3::new(0.0, 0.0, 100.0)); + for _ in 0..240 { + // Пол даём, чтобы не падать: игрок упирается в стену, а не вниз. + collision.set_boxes(vec![ + wall, + Aabb::new( + Vec3::new(-256.0, -256.0, -20.0), + Vec3::new(256.0, 256.0, 0.0), + ), + ]); + player.update(&collision, 1.0, 0.0, false, false, 0.0, 1.0 / 60.0); + } + assert!( + player.position.x < 60.0, + "игрок не должен проходить сквозь стену, x={}", + player.position.x + ); + } }