engine/client: игрок — коллизия с BSP-брашами, гравитация, ходьба, прыжок, присед

- engine::player: Collision (BRUSHES/BRUSHSIDES, твёрдые содержимые), AABB-разрешение
  проникновений с подшагами, Player (800 грав., 268 прыжок, 250 ходьба, присед)
- map: спавн на info_player_start, режим ходьбы по умолчанию, V — noclip,
  камера на уровне глаз, обзор мышью
- gm_construct: 2129 брашей коллизии, полёт/ходьба без падений
This commit is contained in:
Niko Marmeladkov 2026-10-12 00:39:12 +03:00
parent 75325668b3
commit 38cebc2cbf
3 changed files with 339 additions and 3 deletions

View file

@ -65,6 +65,10 @@ pub struct MapArgs {
/// Диагностика: сохранить базовые мипы всех материалов в PNG (в каталог)
#[arg(long, value_name = "DIR")]
pub dump_textures: Option<PathBuf>,
/// Начать в режиме свободного полёта (noclip); переключается клавишей V
#[arg(long)]
pub noclip: bool,
}
/// Запускает рендер карты.
@ -105,6 +109,11 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let world = engine::build_world(&bsp, &vfs)?;
info!(elapsed_ms = started.elapsed().as_millis(), "мир собран");
let collision = engine::Collision::from_bsp(&bsp)?;
info!(brushes = collision.brushes.len(), "коллизия собрана");
let mut player = engine::Player::new(world.spawn);
player.noclip = args.noclip;
if let Some(dir) = &args.dump_textures {
std::fs::create_dir_all(dir)?;
for (index, material) in world.materials.iter().enumerate() {
@ -183,6 +192,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
keycode: Some(Keycode::Escape),
..
} => break 'running,
Event::KeyDown {
keycode: Some(Keycode::V),
repeat: false,
..
} => {
player.noclip = !player.noclip;
info!(noclip = player.noclip, "режим перемещения");
}
Event::KeyDown {
keycode: Some(code),
repeat: false,
@ -235,15 +252,35 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
if pressed.contains(&Keycode::LCtrl) {
move_up -= 1.0;
}
if move_forward != 0.0 || move_right != 0.0 || move_up != 0.0 {
if player.noclip {
let speed = if pressed.contains(&Keycode::LShift) {
args.speed * 3.0
} else {
args.speed
};
let scale = speed * dt;
camera.translate(move_forward * scale, move_right * scale, move_up * scale);
player.fly(
move_forward,
move_right,
move_up,
camera.yaw,
camera.pitch,
speed,
dt,
);
} else {
let jump = pressed.contains(&Keycode::Space);
let duck = pressed.contains(&Keycode::LCtrl);
player.update(
&collision,
move_forward,
move_right,
jump,
duck,
camera.yaw,
dt,
);
}
camera.pos = player.eye_position();
if let Err(err) = renderer.render_to_surface(&gfx, &camera) {
warn!("кадр не отрисован: {err:#}");

View file

@ -3,9 +3,11 @@
//! На этом этапе (M3 v0) — только мир и камера; энтити/предсказание появятся позже.
pub mod camera;
pub mod player;
pub mod world;
pub use camera::FlyCamera;
pub use player::{Collision, Player};
pub use world::{
Batch, Material, Skybox, TextureData, World, WorldError, WorldStats, WorldVertex, build_world,
};

297
crates/engine/src/player.rs Normal file
View file

@ -0,0 +1,297 @@
//! Игрок и коллизия с BSP-брашами (AABB против выпуклых брашей).
use formats::bsp::geometry::Geometry;
use formats::bsp::{Bsp, BspError, lump};
use math::{Aabb, Vec3};
/// Выпуклый браш (набор плоскостей, нормали наружу).
#[derive(Debug, Clone)]
pub struct Brush {
pub planes: Vec<(Vec3, f32)>,
}
/// Коллизия мира.
#[derive(Debug, Default)]
pub struct Collision {
pub brushes: Vec<Brush>,
}
impl Collision {
/// Собирает браши из BSP (только «твёрдые»).
pub fn from_bsp(bsp: &Bsp) -> Result<Self, BspError> {
let geometry: Geometry = bsp.geometry()?;
let brush_data = bsp.raw_lump(lump::BRUSHES)?;
let side_data = bsp.raw_lump(lump::BRUSHSIDES)?;
let mut brushes = Vec::new();
for chunk in brush_data.chunks_exact(12) {
let first = i32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
let count = i32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4]));
let contents = i32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4]));
if first < 0 || count < 0 {
continue;
}
// Твёрдые поверхности: SOLID(1), WINDOW(2), PLAYERCLIP(0x20000000), GRATE(8).
const SOLID: i32 = 1;
const WINDOW: i32 = 2;
const GRATE: i32 = 8;
const PLAYERCLIP: i32 = 0x2000_0000;
if contents & (SOLID | WINDOW | GRATE | PLAYERCLIP) == 0 {
continue;
}
let mut planes = Vec::with_capacity(count as usize);
for s in first as usize..(first + count) as usize {
let Some(side) = side_data.get(s * 8..s * 8 + 8) else {
break;
};
let planenum = u16::from_le_bytes([side[0], side[1]]) as usize;
let Some(plane) = geometry.planes.get(planenum) else {
continue;
};
planes.push((
Vec3::new(plane.normal[0], plane.normal[1], plane.normal[2]),
plane.dist,
));
}
if planes.len() >= 4 {
brushes.push(Brush { planes });
}
}
Ok(Self { brushes })
}
/// Пересекается ли AABB с брашем.
fn overlaps(&self, brush: &Brush, aabb: &Aabb) -> bool {
for (normal, dist) in &brush.planes {
let max_dot = aabb.max.x * normal.x + aabb.max.y * normal.y + aabb.max.z * normal.z;
let min_dot = aabb.min.x * normal.x + aabb.min.y * normal.y + aabb.min.z * normal.z;
let _ = max_dot;
if min_dot > *dist + 0.01 {
return false;
}
}
true
}
/// Сдвигает AABB на `delta`, разрешая проникновения. Возвращает
/// (новый AABB, касание земли).
pub fn move_aabb(&self, mut aabb: Aabb, delta: Vec3) -> (Aabb, bool) {
// Подшаги, чтобы не протыкать тонкие стены.
let steps = ((delta.length() / 8.0).ceil() as usize).clamp(1, 8);
let step = delta / steps as f32;
let mut on_ground = false;
for _ in 0..steps {
aabb.min += step;
aabb.max += step;
for _ in 0..4 {
let mut hit = false;
for brush in &self.brushes {
if !self.overlaps(brush, &aabb) {
continue;
}
// Ищем плоскость с наибольшим проникновением.
let mut best_pen = 0.0f32;
let mut best_normal = Vec3::ZERO;
for (normal, dist) in &brush.planes {
let pen = aabb.min.dot(*normal) - dist;
if pen > best_pen {
best_pen = pen;
best_normal = *normal;
}
}
if best_pen > 0.0 {
let push = best_normal * (best_pen + 0.01);
aabb.min += push;
aabb.max += push;
if best_normal.z > 0.6 {
on_ground = true;
}
hit = true;
}
}
if !hit {
break;
}
}
}
(aabb, on_ground)
}
/// Проверка «есть ли опора под AABB» (проба вниз).
pub fn is_on_ground(&self, aabb: Aabb) -> bool {
let probe = Aabb::new(
aabb.min - Vec3::new(0.0, 0.0, 2.0),
aabb.max - Vec3::new(0.0, 0.0, 2.0),
);
self.brushes.iter().any(|b| self.overlaps(b, &probe))
}
}
/// Локальный игрок (v0: ходьба, гравитация, прыжок, приседание).
#[derive(Debug, Clone)]
pub struct Player {
/// Центр по осям X/Y, ступни — по Z.
pub position: Vec3,
pub velocity: Vec3,
pub on_ground: bool,
pub ducked: bool,
pub noclip: bool,
}
/// Полуразмеры AABB стоя/сидя.
pub const STAND_HALF: Vec3 = Vec3::new(16.0, 16.0, 36.0);
pub const DUCK_HALF: Vec3 = Vec3::new(16.0, 16.0, 18.0);
/// Высота глаз над ступнями.
pub const EYE_STAND: f32 = 64.0;
pub const EYE_DUCK: f32 = 46.0;
const GRAVITY: f32 = 800.0;
const JUMP_SPEED: f32 = 268.0;
const WALK_SPEED: f32 = 250.0;
const AIR_ACCEL: f32 = 12.0;
const GROUND_ACCEL: f32 = 10.0;
const FRICTION: f32 = 6.0;
impl Player {
pub fn new(position: Vec3) -> Self {
Self {
position,
velocity: Vec3::ZERO,
on_ground: false,
ducked: false,
noclip: false,
}
}
pub fn half_extents(&self) -> Vec3 {
if self.ducked { DUCK_HALF } else { STAND_HALF }
}
pub fn eye_height(&self) -> f32 {
if self.ducked { EYE_DUCK } else { EYE_STAND }
}
pub fn eye_position(&self) -> Vec3 {
self.position + Vec3::new(0.0, 0.0, self.eye_height())
}
pub fn aabb(&self) -> Aabb {
let half = self.half_extents();
Aabb::new(self.position - half, self.position + half)
}
/// Обновляет игрока за кадр.
///
/// `wish_forward`/`wish_right` — желаемое направление (−1..1),
/// `jump`/`duck` — состояние клавиш.
#[allow(clippy::too_many_arguments)]
pub fn update(
&mut self,
collision: &Collision,
wish_forward: f32,
wish_right: f32,
jump: bool,
duck: bool,
yaw: f32,
dt: f32,
) {
let dt = dt.min(0.05);
if self.noclip {
return;
}
// Приседание.
if duck != self.ducked {
// Проверяем, можно ли встать.
if !duck {
let mut test = self.clone();
test.ducked = false;
let (_, blocked) = collision.move_aabb(test.aabb(), Vec3::ZERO);
let _ = blocked;
let overlaps = collision
.brushes
.iter()
.any(|b| collision.overlaps(b, &test.aabb()));
if !overlaps {
self.ducked = false;
}
} else {
self.ducked = true;
}
}
// Желаемое направление в мире (Source: yaw 0 = +X, +Y = влево).
let (sin_yaw, cos_yaw) = yaw.to_radians().sin_cos();
let forward = Vec3::new(cos_yaw, sin_yaw, 0.0);
let right = Vec3::new(-sin_yaw, cos_yaw, 0.0); // вправо от взгляда
let mut wish = forward * wish_forward + right * wish_right;
if wish.length_squared() > 0.0 {
wish = wish.normalized() * WALK_SPEED;
}
// Ускорение.
if self.on_ground {
self.velocity.x += (wish.x - self.velocity.x) * GROUND_ACCEL * dt;
self.velocity.y += (wish.y - self.velocity.y) * GROUND_ACCEL * dt;
// Трение при отсутствии ввода.
if wish.length_squared() < 1.0 {
let speed = self.velocity.length();
if speed > 0.0 {
let drop = (speed * FRICTION * dt).min(speed);
self.velocity = self.velocity * ((speed - drop) / speed);
}
}
} else {
self.velocity.x += (wish.x - self.velocity.x) * AIR_ACCEL * dt;
self.velocity.y += (wish.y - self.velocity.y) * AIR_ACCEL * dt;
}
// Прыжок.
if jump && self.on_ground {
self.velocity.z = JUMP_SPEED;
self.on_ground = false;
}
// Гравитация.
self.velocity.z -= GRAVITY * dt;
if self.velocity.z < -2000.0 {
self.velocity.z = -2000.0;
}
// Перемещение с коллизией.
let (new_aabb, touched_ground) = collision.move_aabb(self.aabb(), self.velocity * dt);
self.position = new_aabb.center();
self.position.z = new_aabb.min.z; // ступни = низ AABB
self.on_ground = touched_ground || collision.is_on_ground(new_aabb);
if self.on_ground && self.velocity.z < 0.0 {
self.velocity.z = 0.0;
}
}
/// Свободный полёт (noclip).
#[allow(clippy::too_many_arguments)]
pub fn fly(
&mut self,
wish_forward: f32,
wish_right: f32,
wish_up: f32,
yaw: f32,
pitch: f32,
speed: f32,
dt: f32,
) {
let (sin_yaw, cos_yaw) = yaw.to_radians().sin_cos();
let (sin_pitch, cos_pitch) = pitch.to_radians().sin_cos();
let forward = Vec3::new(cos_pitch * cos_yaw, cos_pitch * sin_yaw, -sin_pitch);
let right = Vec3::new(-sin_yaw, cos_yaw, 0.0);
let mut wish = forward * wish_forward + right * wish_right + Vec3::new(0.0, 0.0, wish_up);
if wish.length_squared() > 0.0 {
wish = wish.normalized();
}
self.position += wish * speed * dt;
}
}