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:
parent
75325668b3
commit
38cebc2cbf
3 changed files with 339 additions and 3 deletions
|
|
@ -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:#}");
|
||||
|
|
|
|||
|
|
@ -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
297
crates/engine/src/player.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue