From 38cebc2cbf084005358f915d03d7a265e8f92713 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Mon, 12 Oct 2026 00:39:12 +0300 Subject: [PATCH] =?UTF-8?q?engine/client:=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=E2=80=94=20=D0=BA=D0=BE=D0=BB=D0=BB=D0=B8=D0=B7=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20BSP-=D0=B1=D1=80=D0=B0=D1=88=D0=B0=D0=BC=D0=B8,=20?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D0=B0=D1=86=D0=B8=D1=8F,?= =?UTF-8?q?=20=D1=85=D0=BE=D0=B4=D1=8C=D0=B1=D0=B0,=20=D0=BF=D1=80=D1=8B?= =?UTF-8?q?=D0=B6=D0=BE=D0=BA,=20=D0=BF=D1=80=D0=B8=D1=81=D0=B5=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine::player: Collision (BRUSHES/BRUSHSIDES, твёрдые содержимые), AABB-разрешение проникновений с подшагами, Player (800 грав., 268 прыжок, 250 ходьба, присед) - map: спавн на info_player_start, режим ходьбы по умолчанию, V — noclip, камера на уровне глаз, обзор мышью - gm_construct: 2129 брашей коллизии, полёт/ходьба без падений --- crates/client/src/map.rs | 43 +++++- crates/engine/src/lib.rs | 2 + crates/engine/src/player.rs | 297 ++++++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/player.rs diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index ae5ac4c..5c52158 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -65,6 +65,10 @@ pub struct MapArgs { /// Диагностика: сохранить базовые мипы всех материалов в PNG (в каталог) #[arg(long, value_name = "DIR")] pub dump_textures: Option, + + /// Начать в режиме свободного полёта (noclip); переключается клавишей V + #[arg(long)] + pub noclip: bool, } /// Запускает рендер карты. @@ -105,6 +109,11 @@ pub fn map(assets: Option, 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, 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, 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:#}"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 898a0e6..bdf684f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, }; diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs new file mode 100644 index 0000000..58515b6 --- /dev/null +++ b/crates/engine/src/player.rs @@ -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, +} + +impl Collision { + /// Собирает браши из BSP (только «твёрдые»). + pub fn from_bsp(bsp: &Bsp) -> Result { + 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; + } +}