engine: пропы добавлены в коллизию игрока — проваливание под землю
Главная жалоба: игрок проваливался при хождении и стоянии. Измерение сузило область: 180 кадров стояния на спавне стабильны (z = -143.9, on_ground = true, провала нет). Значит дефект локальный. Причина найдена архитектурно: коллизия игрока содержала ТОЛЬКО браши карты и треугольники displacement. Пропы живут в мире Rapier, а игрок ходит своей кодовой базой — то есть встать на ящик означало шагнуть в пустоту. Это два непересекающихся мира коллизий. - Collision::boxes + сетка по X/Y: габариты подвижных тел; - set_boxes() перестраивает сетку целиком каждый кадр — тел десятки, а пересборка убирает риск «забыть обновить» (источник тихих провалов); - aabb_penetration() выталкивает по оси наименьшего проникновения, чтобы тело не застревало в углу; - клиент каждый кадр берёт габариты из живого мира, вместе с падающими пропами. Три теста: игрок стоит на теле, стена из тел не пропускает, и отдельный тест фиксирует исходное поведение «без габаритов — проваливается», чтобы регрессия была видна явно. Тестов 69 в engine, 23 набора по воркспейсу, clippy чист.
This commit is contained in:
parent
32bd7c05f2
commit
2afa265fed
2 changed files with 224 additions and 2 deletions
|
|
@ -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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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 {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,19 @@ pub struct Brush {
|
|||
#[derive(Debug, Default)]
|
||||
pub struct Collision {
|
||||
pub brushes: Vec<Brush>,
|
||||
/// Габариты подвижных тел (пропы, предметы) — меняются каждый кадр.
|
||||
///
|
||||
/// Без них игрок **проходит сквозь** любую динамическую сущность: встать
|
||||
/// на ящик — значит шагнуть в пустоту и провалиться. Это отдельный мир
|
||||
/// коллизий от брашей карты, и он обязан быть частью `Collision`, а не
|
||||
/// жить только в Rapier: игрок ходит своей кодовой базой.
|
||||
pub boxes: Vec<Aabb>,
|
||||
/// Треугольники displacement'ов.
|
||||
tris: Vec<[Vec3; 3]>,
|
||||
/// Сетка по X/Y: ячейка → индексы треугольников.
|
||||
grid: HashMap<(i32, i32), Vec<u32>>,
|
||||
/// Сетка по X/Y: ячейка → индексы габаритов тел.
|
||||
box_grid: HashMap<(i32, i32), Vec<u32>>,
|
||||
}
|
||||
|
||||
/// Сторона ячейки пространственной сетки.
|
||||
|
|
@ -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<Aabb>) {
|
||||
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<u32> {
|
||||
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<Vec3> {
|
||||
// Нет пересечения по всем трём осям — тело не мешает.
|
||||
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<Vec3> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue