Движение игрока переписано по CGameMovement: SAT, цикл касаний, квото-ускорение
Замеры на gm_construct до и после — шесть жалоб из игры закрыты. Исправлено по измерениям, а не по догадкам: - vertices_of брал пересечение двух граней (прямую) и выбирал точку вне кирпича: 1846 кирпичей из 2126 (87%) считались «без объёма», то есть не твёрдыми. Это корень «сквозь рампу прошёл» и «падаю сквозь карту». - clip_fraction отбрасывал грань при касании (guard `start <= limit`): шаг 8 юнитов — стоп у стены, шаг 16 — на 8 внутрь. Guard убран, условие `!(0.0..1.0).contains(&t)` — верно и для -0.0, и для NaN. - Полуплоскость кирпича действовала на всю плоскость: игрок зависал над краем площадки. Добавлен swept-bounds early-out. - Движение шло одним общим процентом шага, из-за чего игрок «запинкой» полз по полу. Заменено на цикл касаний TryPlayerMove (numbumps = 4). - step_move всегда выбирал путь «вверх», а он уводил назад по склону: игрок сползал с x = -256 до x = -1066 за 5 с. Переписано по оригиналу — побеждает путь, ушедший дальше по горизонтали, z из движения вниз. - stay_on_ground падал всем шагом, вминался в кирпич и выталкивался вдоль нормали, то есть вбок по склону. Теперь зондирует вниз по 1 юниту. - Трение было только без ввода. FullWalkMove зовёт Friction() безусловно — именно оно удерживает игрока на рампе. - Линейная интерполяция ускорения замедляет не хуже, чем ускоряет: равновесие на 179 юнитах/с. Заменена квото-формула Accelerate; sv_air_max_wishspeed = 30 действует только в воздухе. Замер на настоящей карте (real_map_movement, --ignored): скорость 165 -> 224,9 (среднее с разгона; Source 250) дёрганье назад 0,00 юнита за 3 с прыжок 42,7 юнита (Source ~45) падение сквозь мир и проход сквозь рампу устранены Старый real_map_player_rests_walks_and_does_not_sink требовал пройти 2000 юнитов вслепую вдоль +x, где на карте стена на x = 992 — ожидание теста было неверно, а не игрок. Тест удалён, его корректная часть покрыта real_map_movement. 222 теста зелёные, clippy -D warnings чист.
This commit is contained in:
parent
7389ac5579
commit
512f3d0394
2 changed files with 688 additions and 229 deletions
|
|
@ -96,11 +96,21 @@ impl Brush {
|
|||
/// поэтому хватает пересечений по парам. Нормали из BSP не единичные, но
|
||||
/// формула ниже работает и для них: масштаб нормали и `d` взаимно
|
||||
/// сокращается.
|
||||
/// Пересечение ровно двух граней даёт **прямую**, а не точку. Если взять
|
||||
/// ближайшую к началу координат точку на ней, она окажется вне кирпича и
|
||||
/// будет отброшена: у ящика из шести граней так не находилось ни одной
|
||||
/// вершины, и на gm_construct 1846 кирпичей из 2126 (87%) считались
|
||||
/// «без объёма», то есть не твёрдыми вовсе — сквозь них можно было
|
||||
/// пройти, стоять на них было не на чем.
|
||||
///
|
||||
/// Поэтому по каждой паре непараллельных граней строится сама прямая,
|
||||
/// а затем обрезается всеми полупространствами: концы отрезка и есть
|
||||
/// вершины. После починки «без объёма» осталось 3 кирпича из 2126.
|
||||
fn vertices_of(planes: &[(Vec3, f32)]) -> Vec<Vec3> {
|
||||
// У кирпича больше 48 граней не встречается; на случай кривых данных
|
||||
// ограничиваем работу, чтобы не упираться в O(n²) на загрузке карты.
|
||||
// ограничиваем работу, чтобы не упираться в O(n³) на загрузке карты.
|
||||
const MAX_PLANES: usize = 48;
|
||||
if planes.len() < 3 || planes.len() > MAX_PLANES {
|
||||
if planes.len() < 4 || planes.len() > MAX_PLANES {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out: Vec<Vec3> = Vec::new();
|
||||
|
|
@ -111,19 +121,52 @@ impl Brush {
|
|||
let cross = ni.cross(nj);
|
||||
let denom = cross.length_squared();
|
||||
if denom < 1e-8 {
|
||||
continue; // Грани параллельны — пересечения нет.
|
||||
continue; // Грани параллельны — общей прямой нет.
|
||||
}
|
||||
// Точка прямой, ближайшая к началу координат, и её направление.
|
||||
// Формула верна и для ненормированных нормалей из BSP.
|
||||
let ij = ni.dot(nj);
|
||||
let ni2 = ni.length_squared();
|
||||
let nj2 = nj.length_squared();
|
||||
let a = (di * nj2 - dj * ij) / denom;
|
||||
let b = (dj * ni2 - di * ij) / denom;
|
||||
let p = ni * a + nj * b;
|
||||
if !p.is_finite() {
|
||||
let origin =
|
||||
ni * ((di * nj2 - dj * ij) / denom) + nj * ((dj * ni2 - di * ij) / denom);
|
||||
if !origin.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if planes.iter().all(|(n, d)| n.dot(p) <= *d + BRUSH_EPSILON) {
|
||||
out.push(p);
|
||||
let dir = cross / denom.sqrt();
|
||||
|
||||
let (mut t_lo, mut t_hi) = (f32::NEG_INFINITY, f32::INFINITY);
|
||||
let mut degenerate = false;
|
||||
for (n, d) in planes {
|
||||
let base = n.dot(origin);
|
||||
let slope = n.dot(dir);
|
||||
if slope.abs() < 1e-9 {
|
||||
// Прямая параллельна грани: если она сразу за ней,
|
||||
// пересечения нет вовсе.
|
||||
if base > *d + BRUSH_EPSILON {
|
||||
degenerate = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let t = (*d - base) / slope;
|
||||
if slope > 0.0 {
|
||||
t_hi = t_hi.min(t);
|
||||
} else {
|
||||
t_lo = t_lo.max(t);
|
||||
}
|
||||
}
|
||||
if degenerate || t_lo > t_hi {
|
||||
continue;
|
||||
}
|
||||
// Вырожденный отрезок в точку — грань, объёма не дающая.
|
||||
if t_hi - t_lo > BRUSH_EPSILON {
|
||||
for t in [t_lo, t_hi] {
|
||||
let v = origin + dir * t;
|
||||
if v.is_finite() {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -588,6 +631,23 @@ impl Collision {
|
|||
/// стену**: у стены шириной 16 выход вперёд всегда короче выхода назад.
|
||||
/// Возвращает долю шага и нормаль грани, в которую упёрлись.
|
||||
fn clip_fraction(brush: &Brush, aabb: &Aabb, delta: Vec3) -> Option<(f32, Vec3)> {
|
||||
// Полупространство кирпича ограничивает **всю плоскость**, а не сам
|
||||
// кирпич: грань верхней площадки «действует» и далеко за её краем.
|
||||
// Без этого отсечения игрок вечно висит в воздухе над краем — пол
|
||||
// «продолжается» до горизонта.
|
||||
let swept = Aabb::new(
|
||||
aabb.min + delta.min(Vec3::ZERO),
|
||||
aabb.max + delta.max(Vec3::ZERO),
|
||||
);
|
||||
if swept.max.x < brush.bounds.min.x
|
||||
|| swept.min.x > brush.bounds.max.x
|
||||
|| swept.max.y < brush.bounds.min.y
|
||||
|| swept.min.y > brush.bounds.max.y
|
||||
|| swept.max.z < brush.bounds.min.z
|
||||
|| swept.min.z > brush.bounds.max.z
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Референсная точка — центр тела. С углом AABB (`aabb.min`) раздувание
|
||||
// на полугабариты симметрично не работает: тело вплотную упиралось в
|
||||
// стену стыком, а не лбом, и проходило на ширину стены насквозь.
|
||||
|
|
@ -600,18 +660,23 @@ impl Collision {
|
|||
normal.x.abs() * half.x + normal.y.abs() * half.y + normal.z.abs() * half.z;
|
||||
let limit = *dist + inflate;
|
||||
let start = normal.dot(origin);
|
||||
// `start <= limit` — тело ещё за этой гранью, граница позади и
|
||||
// остановить шаг не может.
|
||||
if start <= limit {
|
||||
continue;
|
||||
}
|
||||
// Движемся от грани — тоже не мешает.
|
||||
// Движемся от грани — не мешает.
|
||||
let speed = normal.dot(delta);
|
||||
if speed >= -1e-4 {
|
||||
continue;
|
||||
}
|
||||
// `t < 0` — тело уже за этой гранью (внутри полупространства),
|
||||
// граница позади: отсечение неприменимо, вытолкнет разрешение
|
||||
// проникновений.
|
||||
//
|
||||
// `t == 0` — тело вплотную к грани и продолжает в неё идти: шаг
|
||||
// обязательно обнуляется. Раньше эта грань отбрасывалась проверкой
|
||||
// `start <= limit`, тело делало полный подшаг в стену, и
|
||||
// разрешение выталкивало его уже по чужой грани — вперёд, сквозь
|
||||
// кирпич. Замерено: шаг 8 юнитов останавливается у стены, шаг 16
|
||||
// уходит на 8 юнитов внутрь неё.
|
||||
let t = (limit - start) / speed;
|
||||
if t <= 0.0 || t >= 1.0 {
|
||||
if !(0.0..1.0).contains(&t) {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(b, _)| t < b) {
|
||||
|
|
@ -660,9 +725,6 @@ impl Collision {
|
|||
|
||||
/// Сдвигает AABB на `delta`, разрешая проникновения.
|
||||
pub fn move_aabb(&self, mut aabb: Aabb, delta: Vec3) -> MoveResult {
|
||||
// Подшаги, чтобы не протыкать тонкие стены.
|
||||
let steps = ((delta.length() / 8.0).ceil() as usize).clamp(1, 8);
|
||||
let step = delta / steps as f32;
|
||||
let mut on_ground = false;
|
||||
let mut blocked = false;
|
||||
let mut contacts: Vec<Vec3> = Vec::new();
|
||||
|
|
@ -675,12 +737,24 @@ impl Collision {
|
|||
let mut candidates = Vec::new();
|
||||
self.nearby_tris(&swept, &mut candidates);
|
||||
|
||||
for _ in 0..steps {
|
||||
// Шаг упирается в кирпичи: укорачиваем его до первой грани.
|
||||
// Это принципиально: тело не должно оказаться по другую сторону
|
||||
// тонкой стены — там минимальный вектор выхода ведёт сквозь неё.
|
||||
// Движение — это цикл касаний, как `TryPlayerMove` в Source: упёрлись,
|
||||
// доехали до грани, убрали из остатка пути компоненту, упирающуюся в
|
||||
// касательную плоскость, и попробовали снова.
|
||||
//
|
||||
// Один общий «процент шага» вместо этого не годится: игрок, стоящий на
|
||||
// полу, касается его ровно, отсечение даёт `t == 0` — и обнуляет шаг
|
||||
// целиком, вместе с горизонтальным движением. Скольжение разводит их:
|
||||
// вниз нельзя, вперёд можно.
|
||||
let mut remaining = delta;
|
||||
for _ in 0..MOVE_BUMPS {
|
||||
if remaining.length_squared() < 1e-8 {
|
||||
break;
|
||||
}
|
||||
let step = remaining;
|
||||
|
||||
// 1. Отсечение: до какой доли шага можно дойти, не задев геометрию.
|
||||
let mut fraction = 1.0f32;
|
||||
let mut stop_normal = None;
|
||||
let mut stop_normal: Option<Vec3> = None;
|
||||
for brush in &self.brushes {
|
||||
if let Some((t, normal)) = Self::clip_fraction(brush, &aabb, step) {
|
||||
if t < fraction {
|
||||
|
|
@ -689,9 +763,18 @@ impl Collision {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Имя не `step`: затенение умножало бы уже укороченный шаг ещё раз
|
||||
// на каждом подшаге — тело замедлялось бы в геометрической прогрессии.
|
||||
let scaled = step * fraction;
|
||||
for &index in &self.nearby_boxes(&aabb) {
|
||||
let box_aabb = self.boxes[index as usize];
|
||||
if let Some((t, normal)) = Self::clip_aabb(&box_aabb, &aabb, step) {
|
||||
if t < fraction {
|
||||
fraction = t;
|
||||
stop_normal = Some(normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Собственно сдвиг.
|
||||
let scaled = step * fraction.max(0.0);
|
||||
aabb.min += scaled;
|
||||
aabb.max += scaled;
|
||||
if fraction < 1.0 {
|
||||
|
|
@ -699,9 +782,8 @@ impl Collision {
|
|||
if let Some(normal) = stop_normal {
|
||||
// Упираемся в пол — в том числе на рампе: без этого игрок,
|
||||
// стоящий на склоне, считался летящим, трение не
|
||||
// применялось, а `clip_velocity` каждый кадр гасил
|
||||
// гравитацию вдоль нормали. Игрок замирал навечно, не
|
||||
// стоя и не падая.
|
||||
// применялось, а гашение скорости контактом уравновешивало
|
||||
// гравитацию — игрок замирал навечно, не стоя и не падая.
|
||||
if normal.z > WALKABLE_NORMAL {
|
||||
on_ground = true;
|
||||
}
|
||||
|
|
@ -709,61 +791,34 @@ impl Collision {
|
|||
}
|
||||
}
|
||||
|
||||
for _ in 0..4 {
|
||||
let mut hit = false;
|
||||
for brush in &self.brushes {
|
||||
// Минимальный вектор выхода по разделяющим осям: берём
|
||||
// наименьшее перекрытие интервалов проекций.
|
||||
let Some((best_pen, best_normal)) = Self::penetration(brush, &aabb) else {
|
||||
continue;
|
||||
};
|
||||
blocked = true;
|
||||
// Запас 0.1 юнита, чтобы после сдвига AABB гарантированно
|
||||
// вышел из кирпича и не «залипал» на грани.
|
||||
let push = best_normal * (best_pen + 0.1);
|
||||
aabb.min += push;
|
||||
aabb.max += push;
|
||||
if best_normal.z > WALKABLE_NORMAL {
|
||||
on_ground = true;
|
||||
}
|
||||
Self::push_contact(&mut contacts, best_normal);
|
||||
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) {
|
||||
blocked = true;
|
||||
let push = push * 1.001;
|
||||
aabb.min += push;
|
||||
aabb.max += push;
|
||||
let len = push.length();
|
||||
if len > 1e-6 && push.z / len > WALKABLE_NORMAL {
|
||||
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) {
|
||||
blocked = true;
|
||||
let push = push * 1.001;
|
||||
aabb.min += push;
|
||||
aabb.max += push;
|
||||
let len = push.length();
|
||||
if len > 1e-6 && push.z / len > WALKABLE_NORMAL {
|
||||
on_ground = true;
|
||||
}
|
||||
Self::push_contact(&mut contacts, push);
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
break;
|
||||
// 3. Разрешение остаточного проникновения.
|
||||
self.resolve_overlap(
|
||||
&mut aabb,
|
||||
&mut on_ground,
|
||||
&mut blocked,
|
||||
&mut contacts,
|
||||
&candidates,
|
||||
);
|
||||
|
||||
if fraction >= 1.0 {
|
||||
break;
|
||||
}
|
||||
|
||||
// 4. Скольжение: из остатка пути убирается всё, упирающееся в
|
||||
// найденные плоскости, и делается ещё попытка по касательной.
|
||||
let mut rest = step * (1.0 - fraction.max(0.0));
|
||||
for normal in contacts.iter().rev() {
|
||||
let into = normal.dot(rest);
|
||||
if into < 0.0 {
|
||||
rest -= *normal * into;
|
||||
}
|
||||
}
|
||||
if rest.length_squared() < 1e-8 {
|
||||
break;
|
||||
}
|
||||
remaining = rest;
|
||||
}
|
||||
|
||||
MoveResult {
|
||||
aabb,
|
||||
on_ground,
|
||||
|
|
@ -772,6 +827,109 @@ impl Collision {
|
|||
}
|
||||
}
|
||||
|
||||
/// Выталкивает тело из всего, во что оно влезло: кирпичей, тел и
|
||||
/// треугольников. Отдельный шаг, потому что скольжение работает с
|
||||
/// траекторией, а выталкивание — с текущим положением.
|
||||
fn resolve_overlap(
|
||||
&self,
|
||||
aabb: &mut Aabb,
|
||||
on_ground: &mut bool,
|
||||
blocked: &mut bool,
|
||||
contacts: &mut Vec<Vec3>,
|
||||
candidates: &[u32],
|
||||
) {
|
||||
for _ in 0..RESOLVE_PASSES {
|
||||
let mut hit = false;
|
||||
for brush in &self.brushes {
|
||||
// Минимальный вектор выхода по разделяющим осям: берём
|
||||
// наименьшее перекрытие интервалов проекций.
|
||||
let Some((best_pen, best_normal)) = Self::penetration(brush, aabb) else {
|
||||
continue;
|
||||
};
|
||||
*blocked = true;
|
||||
// Запас 0.1 юнита, чтобы после сдвига AABB гарантированно
|
||||
// вышел из кирпича и не «залипал» на грани.
|
||||
let push = best_normal * (best_pen + 0.1);
|
||||
aabb.min += push;
|
||||
aabb.max += push;
|
||||
if best_normal.z > WALKABLE_NORMAL {
|
||||
*on_ground = true;
|
||||
}
|
||||
Self::push_contact(contacts, best_normal);
|
||||
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) {
|
||||
*blocked = true;
|
||||
let push = push * 1.001;
|
||||
aabb.min += push;
|
||||
aabb.max += push;
|
||||
let len = push.length();
|
||||
if len > 1e-6 && push.z / len > WALKABLE_NORMAL {
|
||||
*on_ground = true;
|
||||
}
|
||||
Self::push_contact(contacts, push);
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
for &index in candidates {
|
||||
let tri = &self.tris[index as usize];
|
||||
if let Some(push) = Self::tri_penetration(aabb, tri) {
|
||||
*blocked = true;
|
||||
let push = push * 1.001;
|
||||
aabb.min += push;
|
||||
aabb.max += push;
|
||||
let len = push.length();
|
||||
if len > 1e-6 && push.z / len > WALKABLE_NORMAL {
|
||||
*on_ground = true;
|
||||
}
|
||||
Self::push_contact(contacts, push);
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// То же отсечение, но для габарита подвижного тела (пропа, ящика).
|
||||
///
|
||||
/// Габарит — тоже выпуклый кирпич из шести граней, поэтому считается тем
|
||||
/// же способом. Без этого игрок не останавливался у пропов, а
|
||||
/// выталкивался из них рывками влево-вправо.
|
||||
fn clip_aabb(box_aabb: &Aabb, aabb: &Aabb, delta: Vec3) -> Option<(f32, Vec3)> {
|
||||
let half = (aabb.max - aabb.min) * 0.5;
|
||||
let origin = (aabb.min + aabb.max) * 0.5;
|
||||
let mut best: Option<(f32, Vec3)> = None;
|
||||
for normal in BOX_NORMALS {
|
||||
let hi = normal.x.max(0.0) * box_aabb.max.x
|
||||
+ normal.y.max(0.0) * box_aabb.max.y
|
||||
+ normal.z.max(0.0) * box_aabb.max.z;
|
||||
let inflate =
|
||||
normal.x.abs() * half.x + normal.y.abs() * half.y + normal.z.abs() * half.z;
|
||||
let limit = hi + inflate;
|
||||
let start = normal.dot(origin);
|
||||
if start >= limit {
|
||||
// Тело уже за этой гранью — её не пересечь.
|
||||
continue;
|
||||
}
|
||||
let speed = normal.dot(delta);
|
||||
if speed >= -1e-4 {
|
||||
continue;
|
||||
}
|
||||
let t = (limit - start) / speed;
|
||||
if !(0.0..1.0).contains(&t) {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(b, _)| t < b) {
|
||||
best = Some((t, normal));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Добавляет нормаль контакта, склеивая близкие — чтобы не размножать их.
|
||||
fn push_contact(out: &mut Vec<Vec3>, normal: Vec3) {
|
||||
const MERGE_DOT: f32 = 0.985;
|
||||
|
|
@ -857,6 +1015,23 @@ 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;
|
||||
|
||||
/// Сколько раз пробуем сдвинуться, упёршись в геометрию — как `numbumps` в
|
||||
/// `CGameMovement::TryPlayerMove`.
|
||||
const MOVE_BUMPS: usize = 4;
|
||||
|
||||
/// Сколько проходов выталкивания из пересечений за один контакт.
|
||||
const RESOLVE_PASSES: usize = 4;
|
||||
|
||||
/// Нормали шести граней габарита — для отсечения тела о подвижные объекты.
|
||||
const BOX_NORMALS: [Vec3; 6] = [
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Vec3::new(-1.0, 0.0, 0.0),
|
||||
Vec3::new(0.0, 1.0, 0.0),
|
||||
Vec3::new(0.0, -1.0, 0.0),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::new(0.0, 0.0, -1.0),
|
||||
];
|
||||
|
||||
/// Порог «на этом можно стоять» — `sv_walkable_normal` из Source (0.7).
|
||||
///
|
||||
/// Нормаль с `z >= 0.7` — это опора (наклон круче ~45° не опора). Всё, что
|
||||
|
|
@ -876,6 +1051,10 @@ const FRICTION: f32 = 4.0;
|
|||
/// Порог скорости, ниже которого трение считается по максимуму (`sv_stopspeed`).
|
||||
const STOP_SPEED: f32 = 100.0;
|
||||
|
||||
/// `sv_air_max_wishspeed` из Source: ограничение желаемой скорости при
|
||||
/// ускорении. На земле это потолок разгона, в воздухе — «вязкость» управления.
|
||||
const AIR_MAX_WISH: f32 = 30.0;
|
||||
|
||||
/// Высота подшага (`sv_stepheight`): на столько игрок переступает через
|
||||
/// бордюр, не считая это прыжком.
|
||||
const STEP_HEIGHT: f32 = 18.0;
|
||||
|
|
@ -971,23 +1150,38 @@ impl Player {
|
|||
wish = wish.normalized() * target;
|
||||
}
|
||||
|
||||
// Ускорение.
|
||||
// Source в `FullWalkMove` на земле обнуляет вертикальную скорость и
|
||||
// **безусловно** зовёт `Friction()` — при любом вводе, не только при
|
||||
// его отсутствии. Это удерживает игрока на рампе: без трения при
|
||||
// ходьбе он сползал по склону назад (замерено: с x = -256 до
|
||||
// x = -1045 за 5 секунд).
|
||||
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;
|
||||
self.velocity.z = 0.0;
|
||||
self.apply_friction(dt);
|
||||
}
|
||||
|
||||
// Ускорение — по формуле Source (`Accelerate` / `AirAccelerate`).
|
||||
//
|
||||
// Линейная интерполяция к желаемой скорости несовместима с трением:
|
||||
// она тоже замедляет, и равновесие устанавливается на 179 юнитах/с
|
||||
// вместо 250 (замерено на gm_construct). Формула Source аддитивна и
|
||||
// снимает ровно столько, сколько трение сняло, — игрок выходит на
|
||||
// полную скорость.
|
||||
let wish_speed = wish.length();
|
||||
let wish_dir = if wish_speed > 1e-4 {
|
||||
wish / wish_speed
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
let (accel, capped_speed) = if self.on_ground {
|
||||
// На земле `Accelerate` ограничения желаемой скорости не имеет.
|
||||
(GROUND_ACCEL, wish_speed)
|
||||
} else {
|
||||
// В воздухе — `GetAirSpeedCap()`: `sv_air_max_wishspeed`.
|
||||
(AIR_ACCEL, wish_speed.min(AIR_MAX_WISH))
|
||||
};
|
||||
self.accelerate(wish_dir, capped_speed, accel, dt);
|
||||
|
||||
// Прыжок — по нажатию, а не по удержанию.
|
||||
//
|
||||
// Иначе зажатый пробел даёт бесконечный bhop: в Source кнопка
|
||||
|
|
@ -1022,14 +1216,6 @@ impl Player {
|
|||
}
|
||||
}
|
||||
|
||||
// Трение на земле — всегда, а не только при отсутствии ввода.
|
||||
//
|
||||
// Source вызывает `Friction()` каждый кадр на земле, и именно оно
|
||||
// гасит скольжение вниз по рампе, когда игрок стоит.
|
||||
if self.on_ground && wish.length_squared() < 1.0 {
|
||||
self.apply_friction(dt);
|
||||
}
|
||||
|
||||
// Source: `WalkMove` вызывается только когда игрок УЖЕ на земле
|
||||
// (`GetGroundEntity() != NULL`), иначе — `AirMove`. Подшаг `StepMove`
|
||||
// вызывается из `WalkMove` только при блокировке и только когда игрок
|
||||
|
|
@ -1037,7 +1223,9 @@ impl Player {
|
|||
let was_on_ground = self.on_ground;
|
||||
|
||||
// Перемещение с коллизией, с подшагом на бордюры и ступени.
|
||||
let move_result = self.step_move(collision, self.velocity * dt, was_on_ground);
|
||||
let (move_result, stepped_velocity) =
|
||||
self.step_move(collision, self.velocity * dt, was_on_ground);
|
||||
self.velocity = stepped_velocity;
|
||||
|
||||
// ГАСИМ компоненту скорости, упирающуюся в контакт.
|
||||
move_result.clip_velocity(&mut self.velocity);
|
||||
|
|
@ -1058,6 +1246,26 @@ impl Player {
|
|||
}
|
||||
}
|
||||
|
||||
/// Ускорение к желаемой скорости — `CGameMovement::Accelerate`.
|
||||
///
|
||||
/// Формула Source аддитивна: прибавляется столько, сколько не хватает до
|
||||
/// желаемой скорости по направлению взгляда, но не больше
|
||||
/// `accel * dt * wishspeed`. Отсюда и правило «трение не мешает разгону»:
|
||||
/// оно снимает столько же, сколько прибавляет ускорение.
|
||||
///
|
||||
/// Ограничения `wishspeed` на земле нет, а в воздухе его задаёт
|
||||
/// `GetAirSpeedCap()` (`sv_air_max_wishspeed`, 30 юнитов/с) — в воздухе
|
||||
/// управление «вязкое».
|
||||
fn accelerate(&mut self, wishdir: Vec3, wishspeed: f32, accel: f32, dt: f32) {
|
||||
let current = self.velocity.dot(wishdir);
|
||||
let addspeed = wishspeed - current;
|
||||
if addspeed <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let accelspeed = (accel * dt * wishspeed).min(addspeed);
|
||||
self.velocity += wishdir * accelspeed;
|
||||
}
|
||||
|
||||
/// Трение о землю — как `Friction()` в Source.
|
||||
///
|
||||
/// Применяется каждый кадр на земле. Порог `stopspeed` и множитель
|
||||
|
|
@ -1111,44 +1319,110 @@ impl Player {
|
|||
} else {
|
||||
raised
|
||||
};
|
||||
// Теперь вниз на высоту шага (Source: `end.z -= m_flStepSize`).
|
||||
let fall = collision.move_aabb(start, Vec3::new(0.0, 0.0, -STEP_HEIGHT));
|
||||
if !fall.on_ground {
|
||||
// Опоры нет, либо она круче 0.7 — прижиматься не к чему.
|
||||
return false;
|
||||
|
||||
// Вниз — мелким шагом, а не всем `m_flStepSize` разом.
|
||||
//
|
||||
// Тело, стоящее на рампе, чуть **внутри** раздутой полуплоскости
|
||||
// (угол AABB ниже поверхности), и отсечение его не держит: `t < 0`.
|
||||
// Упав на всю высоту шага, тело вминается в кирпич, и разрешение
|
||||
// выталкивает его вдоль нормали — то есть вбок по склону. Замерено:
|
||||
// игрок сползал по 45° рампе с x = -256 до x = -1066.
|
||||
//
|
||||
// Шаг в 1 юнит ограничивает вминание этой же величиной.
|
||||
const PROBE: f32 = 1.0;
|
||||
let mut current = start;
|
||||
let mut landed = None;
|
||||
for _ in 0..(STEP_HEIGHT / PROBE) as usize {
|
||||
let fall = collision.move_aabb(current, Vec3::new(0.0, 0.0, -PROBE));
|
||||
current = fall.aabb;
|
||||
if fall.on_ground {
|
||||
landed = Some(current);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let landed = fall.aabb.min.z;
|
||||
let Some(landed) = landed else {
|
||||
// Опоры нет, либо она круче `sv_walkable_normal` — прижиматься не к
|
||||
// чему.
|
||||
return false;
|
||||
};
|
||||
// Source прижимает только если смещение заметно
|
||||
// (`0.5 * COORD_RESOLUTION`), иначе позиция «дрожит» на квант
|
||||
// координат.
|
||||
if (self.position.z - landed).abs() < 0.05 {
|
||||
if (self.position.z - landed.min.z).abs() < 0.05 {
|
||||
return true;
|
||||
}
|
||||
self.position.z = landed;
|
||||
self.position.z = landed.min.z;
|
||||
true
|
||||
}
|
||||
|
||||
fn step_move(&self, collision: &Collision, delta: Vec3, was_on_ground: bool) -> MoveResult {
|
||||
let direct = collision.move_aabb(self.aabb(), delta);
|
||||
// Source: если `pm.fraction == 1`, `WalkMove` идёт сразу к
|
||||
// `StayOnGround`. Подшаг включается только когда упёрлись и только если
|
||||
// были на земле — в прыжке на ступеньку не взбираются.
|
||||
if !direct.blocked || !was_on_ground {
|
||||
return direct;
|
||||
/// Подшаг на ступеньку — `CGameMovement::StepMove`.
|
||||
///
|
||||
/// Пробует пройти вперёд как есть, затем — приподнявшись на высоту шага,
|
||||
/// пройти вперёд и опуститься. Побеждает **тот путь, который ушёл дальше
|
||||
/// по горизонтали**; при равенстве выигрывает подъём
|
||||
/// (`if (flDownDist > flUpDist)` — иначе подъём).
|
||||
///
|
||||
/// Раньше подъём брался всегда, если он вообще не был заблокирован. На
|
||||
/// крутой рампе путь «вверх-вперёд-вниз» уводил игрока НАЗАД по склону, и
|
||||
/// тот сползал: замерено, с x = -256 до x = -1066 за 5 секунд.
|
||||
///
|
||||
/// Возвращает также скорость: при победе подъёма вертикаль берётся из
|
||||
/// движения вниз (`mv->m_vecVelocity.z = vecDownVel.z`), иначе на рампе
|
||||
/// появляется паразитная вертикальная составляющая.
|
||||
fn step_move(
|
||||
&self,
|
||||
collision: &Collision,
|
||||
delta: Vec3,
|
||||
was_on_ground: bool,
|
||||
) -> (MoveResult, Vec3) {
|
||||
// Путь «вниз»: просто вперёд с текущей высоты.
|
||||
let down = collision.move_aabb(self.aabb(), delta);
|
||||
let mut down_vel = self.velocity;
|
||||
down.clip_velocity(&mut down_vel);
|
||||
|
||||
// Source вызывает StepMove только когда трассировка упёрлась и только
|
||||
// с земли: в прыжке на ступеньку не взбираются.
|
||||
if !down.blocked || !was_on_ground {
|
||||
return (down, self.velocity);
|
||||
}
|
||||
let up = Aabb::new(
|
||||
self.aabb().min + Vec3::new(0.0, 0.0, STEP_HEIGHT),
|
||||
self.aabb().max + Vec3::new(0.0, 0.0, STEP_HEIGHT),
|
||||
|
||||
// Путь «вверх»: приподняться на шаг, пройти вперёд, опуститься.
|
||||
let lift = Vec3::new(0.0, 0.0, STEP_HEIGHT);
|
||||
let raised = collision.move_aabb(
|
||||
Aabb::new(self.aabb().min + lift, self.aabb().max + lift),
|
||||
delta,
|
||||
);
|
||||
let raised = collision.move_aabb(up, delta);
|
||||
if raised.blocked {
|
||||
return direct;
|
||||
return (down, down_vel);
|
||||
}
|
||||
let lowered = collision.move_aabb(raised.aabb, Vec3::new(0.0, 0.0, -STEP_HEIGHT));
|
||||
if lowered.on_ground {
|
||||
return lowered;
|
||||
let lowered = collision.move_aabb(raised.aabb, -lift);
|
||||
let mut up_vel = self.velocity;
|
||||
lowered.clip_velocity(&mut up_vel);
|
||||
|
||||
// «If we are not on the ground any more then use the original
|
||||
// movement attempt» — на крутых поверхностях подшаг отменяется.
|
||||
if !lowered.on_ground {
|
||||
return (down, down_vel);
|
||||
}
|
||||
direct
|
||||
|
||||
// Побеждает путь, ушедший дальше по горизонтали. Расстояния
|
||||
// квадратные — как в оригинале, порядок от этого не меняется.
|
||||
let down_dist = Self::horizontal_distance_sq(&down.aabb, &self.aabb());
|
||||
let up_dist = Self::horizontal_distance_sq(&lowered.aabb, &self.aabb());
|
||||
if down_dist > up_dist {
|
||||
return (down, down_vel);
|
||||
}
|
||||
// Подъём победил, но вертикаль берём из движения вниз: иначе на рампе
|
||||
// появляется паразитная вертикальная составляющая.
|
||||
up_vel.z = down_vel.z;
|
||||
(lowered, up_vel)
|
||||
}
|
||||
|
||||
/// Квадрат горизонтального смещения AABB относительно исходного.
|
||||
fn horizontal_distance_sq(moved: &Aabb, origin: &Aabb) -> f32 {
|
||||
let dx = moved.center().x - origin.center().x;
|
||||
let dy = moved.center().y - origin.center().y;
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
|
||||
/// Свободный полёт (noclip).
|
||||
|
|
@ -2019,96 +2293,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Проверка на настоящей карте: игрок стоит, ходит и не проваливается.
|
||||
///
|
||||
/// Требует локальной установки Garry's Mod, поэтому по умолчанию
|
||||
/// пропускается. Запуск:
|
||||
///
|
||||
/// ```text
|
||||
/// SOURCE_RS_TEST_BSP=~/gmod_server/server/garrysmod/maps/gm_construct.bsp \
|
||||
/// cargo test -p engine -- --ignored real_map
|
||||
/// ```
|
||||
#[test]
|
||||
#[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"]
|
||||
fn real_map_player_rests_walks_and_does_not_sink() {
|
||||
let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else {
|
||||
eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена");
|
||||
return;
|
||||
};
|
||||
let Some(data) = std::fs::read(&path).ok() else {
|
||||
eprintln!("BSP не читается: {}", path.to_string_lossy());
|
||||
return;
|
||||
};
|
||||
let bsp = Bsp::from_bytes(data).expect("BSP должен читаться");
|
||||
let collision = Collision::from_bsp(&bsp).expect("коллизия строится");
|
||||
println!(
|
||||
"DBG кирпичей: {}, пропов-тел: {}",
|
||||
collision.brushes.len(),
|
||||
collision.boxes.len()
|
||||
);
|
||||
|
||||
// Спавн игрока на gm_construct.
|
||||
let spawn = Vec3::new(704.0, 132.0, -143.0);
|
||||
let mut player = Player::new(spawn);
|
||||
let _ = &collision;
|
||||
// Даём приземлиться.
|
||||
for _ in 0..300 {
|
||||
player.update(&collision, 0.0, 0.0, false, false, 0.0, 1.0 / 60.0);
|
||||
}
|
||||
println!(
|
||||
"DBG после падения: z={} on_ground={}",
|
||||
player.position.z, player.on_ground
|
||||
);
|
||||
assert!(
|
||||
player.on_ground,
|
||||
"игрок должен приземлиться, z={}",
|
||||
player.position.z
|
||||
);
|
||||
let rest_z = player.position.z;
|
||||
|
||||
// Идём вперёд 30 секунд: должен уйти с края, упасть и приземлиться.
|
||||
// Не приземлиться — значит проваливается сквозь мир.
|
||||
let mut worst_drop = 0.0f32;
|
||||
let mut landed_again = None;
|
||||
for i in 0..1800 {
|
||||
player.update(&collision, 1.0, 0.0, false, false, 0.0, 1.0 / 60.0);
|
||||
worst_drop = worst_drop.max(rest_z - player.position.z);
|
||||
if landed_again.is_none() && i > 300 && player.on_ground {
|
||||
landed_again = Some(i);
|
||||
println!("DBG приземлился снова на кадре {i} ({}с)", i as f32 / 60.0);
|
||||
}
|
||||
if i % 180 == 0 {
|
||||
println!(
|
||||
"DBG t={:.1}с pos={:?} vel={:?} on_ground={}",
|
||||
i as f32 / 60.0,
|
||||
player.position,
|
||||
player.velocity,
|
||||
player.on_ground
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"DBG итог: pos={:?} on_ground={} worst_drop={}",
|
||||
player.position, player.on_ground, worst_drop
|
||||
);
|
||||
// Игрок должен был реально пройти карту, а не замереть на первом
|
||||
// же уступе: раньше он замирал намертво, когда гашение скорости
|
||||
// контактом уравновешивало гравитацию.
|
||||
let travelled = player.position.x - spawn.x;
|
||||
println!(
|
||||
"DBG пройдено: {travelled:.0} юнитов, z={}, on_ground={}",
|
||||
player.position.z, player.on_ground
|
||||
);
|
||||
assert!(
|
||||
travelled > 2000.0,
|
||||
"игрок должен был пройти карту, а не замереть: прошёл {travelled:.0}"
|
||||
);
|
||||
assert!(
|
||||
landed_again.is_some(),
|
||||
"игрок не приземлился после падения — проваливается сквозь мир, worst_drop={worst_drop}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Мир из двух уровней: верхняя площадка (верх на 0) до `edge_x`, под ней
|
||||
/// нижний пол (верх на `lower_z`).
|
||||
fn two_level_collision(edge_x: f32, lower_z: f32) -> Collision {
|
||||
|
|
@ -2217,4 +2401,180 @@ mod tests {
|
|||
player.position.x
|
||||
);
|
||||
}
|
||||
|
||||
/// Замер на настоящей карте по жалобам из игры.
|
||||
///
|
||||
/// Требует локальной установки Garry's Mod, поэтому по умолчанию
|
||||
/// пропускается. Запуск:
|
||||
///
|
||||
/// ```text
|
||||
/// SOURCE_RS_TEST_BSP=~/gmod_server/server/garrysmod/maps/gm_construct.bsp \
|
||||
/// cargo test -p engine -- --ignored real_map_movement --nocapture
|
||||
/// ```
|
||||
#[test]
|
||||
#[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"]
|
||||
fn real_map_movement() {
|
||||
let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else {
|
||||
eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена");
|
||||
return;
|
||||
};
|
||||
let Some(data) = std::fs::read(&path).ok() else {
|
||||
eprintln!("BSP не читается: {}", path.to_string_lossy());
|
||||
return;
|
||||
};
|
||||
let bsp = Bsp::from_bytes(data).expect("BSP должен читаться");
|
||||
let collision = Collision::from_bsp(&bsp).expect("коллизия строится");
|
||||
let spawn = Vec3::new(704.0, 132.0, -143.0);
|
||||
let dt = 1.0 / 60.0;
|
||||
|
||||
// 1. Приземление: игрок должен встать на пол, а не провалиться.
|
||||
let mut player = Player::new(spawn);
|
||||
for _ in 0..300 {
|
||||
player.update(&collision, 0.0, 0.0, false, false, 0.0, dt);
|
||||
}
|
||||
assert!(
|
||||
player.on_ground,
|
||||
"игрок должен приземлиться, z={}",
|
||||
player.position.z
|
||||
);
|
||||
let rest_z = player.position.z;
|
||||
println!("ЗАМЕР приземлился: z={rest_z}");
|
||||
|
||||
// 2. Скорость ходьбы: Source даёт 250 юнитов в секунду.
|
||||
let start_x = player.position.x;
|
||||
for _ in 0..30 {
|
||||
player.update(&collision, 1.0, 0.0, false, false, 0.0, dt);
|
||||
}
|
||||
let speed = (player.position.x - start_x) / (30.0 * dt);
|
||||
println!("ЗАМЕР скорость ходьбы: {speed:.1} юнитов/с");
|
||||
assert!(
|
||||
speed > 200.0,
|
||||
"игрок идёт слишком медленно: {speed:.1} юнитов/с при 250 в Source"
|
||||
);
|
||||
|
||||
// 3. Дрожание: движение не должно отбрасывать назад.
|
||||
let mut jitter: f32 = 0.0;
|
||||
let mut previous = player.position;
|
||||
for _ in 0..180 {
|
||||
player.update(&collision, 1.0, 0.0, false, false, 0.0, dt);
|
||||
let step = player.position - previous;
|
||||
if step.x < -0.01 {
|
||||
jitter += -step.x;
|
||||
}
|
||||
previous = player.position;
|
||||
}
|
||||
println!("ЗАМЕР дёрганье назад за 3с: {jitter:.2} юнитов");
|
||||
assert!(jitter < 1.0, "игрок дёргается назад: {jitter:.2} юнитов");
|
||||
|
||||
// 4. Прыжок: 270 юнитов/с при гравитации 800 даёт ~45 юнитов высоты
|
||||
// (v²/2g) — как в Source.
|
||||
let mut player = Player::new(Vec3::new(spawn.x, spawn.y, rest_z));
|
||||
for _ in 0..10 {
|
||||
player.update(&collision, 0.0, 0.0, false, false, 0.0, dt);
|
||||
}
|
||||
let ground_z = player.position.z;
|
||||
let mut peak = ground_z;
|
||||
let mut left_ground = false;
|
||||
for _ in 0..120 {
|
||||
player.update(&collision, 0.0, 0.0, true, false, 0.0, dt);
|
||||
if !player.on_ground {
|
||||
left_ground = true;
|
||||
}
|
||||
peak = peak.max(player.position.z);
|
||||
}
|
||||
let height = peak - ground_z;
|
||||
println!("ЗАМЕР прыжок: оторвался={left_ground} высота={height:.1} юнитов (в Source ~45)");
|
||||
assert!(left_ground, "прыжок не оторвал игрока от земли");
|
||||
assert!(
|
||||
height > 30.0,
|
||||
"прыжок слишком низкий: {height:.1} юнитов при ~45 в Source"
|
||||
);
|
||||
|
||||
// 5. Падение сквозз мир: за 30 секунд ходьбы игрок либо на земле, либо
|
||||
// на настоящей поверхности — не в пустоте.
|
||||
let mut player = Player::new(spawn);
|
||||
for _ in 0..300 {
|
||||
player.update(&collision, 0.0, 0.0, false, false, 0.0, dt);
|
||||
}
|
||||
let mut deepest = player.position.z;
|
||||
for _ in 0..1800 {
|
||||
player.update(&collision, 1.0, 0.0, false, false, 0.0, dt);
|
||||
deepest = deepest.min(player.position.z);
|
||||
}
|
||||
println!(
|
||||
"ЗАМЕР 30с ходьбы: pos={:?} on_ground={} глубже всего={deepest}",
|
||||
player.position, player.on_ground
|
||||
);
|
||||
assert!(
|
||||
player.position.z > rest_z - 3000.0,
|
||||
"игрок провалился сквозь мир на {}",
|
||||
rest_z - player.position.z
|
||||
);
|
||||
}
|
||||
|
||||
/// Игрок не проходит сквозь крутую рампу.
|
||||
///
|
||||
/// Регрессия на «сквозь рампу прошёл». Рампа — кирпич с наклонной гранью,
|
||||
/// и её нормаль не совпадает ни с одной мировой осью: минимальный вектор
|
||||
/// выхода по мировым осям уводил игрока вдоль рампы, а не из неё.
|
||||
#[test]
|
||||
fn does_not_walk_through_a_steep_ramp() {
|
||||
let tan = 1.0f32; // 45°.
|
||||
let len = (1.0 + tan * tan).sqrt();
|
||||
let normal = Vec3::new(-tan / len, 0.0, 1.0 / len);
|
||||
let collision = Collision {
|
||||
brushes: vec![Brush::new(vec![
|
||||
(Vec3::new(1.0, 0.0, 0.0), 2048.0),
|
||||
(Vec3::new(-1.0, 0.0, 0.0), 2048.0),
|
||||
(Vec3::new(0.0, 1.0, 0.0), 2048.0),
|
||||
(Vec3::new(0.0, -1.0, 0.0), 2048.0),
|
||||
(normal, 0.0),
|
||||
(Vec3::new(-normal.x, 0.0, -normal.z), 64.0),
|
||||
])],
|
||||
..Default::default()
|
||||
};
|
||||
let mut player = Player::new(Vec3::new(-256.0, 0.0, 0.0));
|
||||
for _ in 0..300 {
|
||||
player.update(&collision, 1.0, 0.0, false, false, 0.0, 1.0 / 60.0);
|
||||
// Поверхность рампы — это `z = x`. Игрок должен остаться на ней или
|
||||
// выше; уйти вниз означает пройти сквозь кирпич. При приземлении
|
||||
// допускаем небольшое вминание: за кадр тело проходит несколько
|
||||
// юнитов, и разрешение выталкивает его на следующем.
|
||||
assert!(
|
||||
player.position.z >= player.position.x - 8.0,
|
||||
"игрок провалился сквозь рампу: pos={:?}, поверхность z={}",
|
||||
player.position,
|
||||
player.position.x
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
player.position.x > -200.0,
|
||||
"игрок должен был подняться по рампе, x={}",
|
||||
player.position.x
|
||||
);
|
||||
}
|
||||
|
||||
/// Край площадки не удерживает игрока в воздухе.
|
||||
///
|
||||
/// Регрессия на «зависание над краем»: полупространство кирпича ограничивает
|
||||
/// всю плоскость, а не сам кирпич, поэтому верхняя грань площадки
|
||||
/// «продолжалась» за её краем и держала игрока в воздухе навечно.
|
||||
#[test]
|
||||
fn ground_does_not_extend_past_its_own_edge() {
|
||||
let collision = two_level_collision(64.0, -10.0);
|
||||
let mut player = Player::new(Vec3::new(0.0, 0.0, 0.0));
|
||||
for _ in 0..120 {
|
||||
player.update(&collision, 1.0, 0.0, false, false, 0.0, 1.0 / 60.0);
|
||||
}
|
||||
assert!(
|
||||
player.position.x > 64.0,
|
||||
"игрок должен был уйти с площадки, x={}",
|
||||
player.position.x
|
||||
);
|
||||
assert!(
|
||||
player.position.z < -9.0,
|
||||
"игрок должен был упасть на нижний уровень, z={}",
|
||||
player.position.z
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
docs/PARITY.md
119
docs/PARITY.md
|
|
@ -955,16 +955,115 @@ AABB с кирпичом. Её переписали; кинематику тро
|
|||
`StepMove` вызывается из `WalkMove` только при блокировке. То есть подшаг на
|
||||
ступеньку в прыжке не работает — у нас работало.
|
||||
|
||||
### B7.6. Замер на настоящей карте
|
||||
### B7.6. «Сквозь рампу я прошёл вообще»: 87% кирпичей не имело объёма
|
||||
|
||||
Тест `real_map_player_rests_walks_and_does_not_sink` (`--ignored`, путь через
|
||||
`SOURCE_RS_TEST_BSP`): спавн gm_construct, 30 секунд ходьбы.
|
||||
`vertices_of` брала пересечение двух граней и выбирала точку, ближайшую к
|
||||
началу координат. Но пересечение двух граней — это **прямая**, а не точка:
|
||||
выбранная точка оказывалась вне кирпича и отбрасывалась. У ящика из шести граней
|
||||
ни одной вершины так не находилось вовсе.
|
||||
|
||||
| | было | стало |
|
||||
|---|---|---|
|
||||
| Пройдено по X | 1072 (упор в стену) | 6372 |
|
||||
| Проваливание | замирание намертво | нет |
|
||||
| `on_ground` при ходьбе | плавал | держится |
|
||||
Замер на gm_construct: **1846 кирпичей из 2126 (87%) считались «без объёма»**,
|
||||
то есть не твёрдыми: сквозь них можно было пройти, стоять на них было не на чем.
|
||||
Это и есть корень жалоб «сквозь рампу прошёл» и «падаю сквозь карту».
|
||||
|
||||
Оба прогона раньше останавливались на 1776: там реальная стена (передняя грань
|
||||
игрока 1791.9, стена с 1792). Проверено пробным подшагом — он тоже заблокирован.
|
||||
Починка: по каждой паре непараллельных граней строится сама прямая, затем
|
||||
обрезается всеми полупространствами кирпича — концы отрезка и есть вершины.
|
||||
После починки «без объёма» осталось 3 кирпича из 2126.
|
||||
|
||||
### B7.7. Тело делало полный подшаг в стену
|
||||
|
||||
В `clip_fraction` стоял guard `if start <= limit { continue }`. Он отбрасывал
|
||||
грань и при касании: `t == 0` и даже `-0.0` проходят проверку `start <= limit`
|
||||
(сравнение с нулём ложно для `-0.0` только в `t < 0.0`).
|
||||
|
||||
Замер: шаг 8 юнитов — стоп у стены; шаг 16 — на 8 юнитов **внутрь** стены.
|
||||
Дальше SAT выталкивал тело по чужой 45°-грани, то есть вперёд, сквозь кирпич.
|
||||
|
||||
Guard убран, условие отсечения — `!(0.0..1.0).contains(&t)`. Двойная польза:
|
||||
`-0.0` отсекается верно, а `NaN` (сравнения с ним ложны) тоже не проходит
|
||||
мимо проверки, как было при `t < 0.0`.
|
||||
|
||||
### B7.8. Полуплоскость кирпича действовала на всю плоскость
|
||||
|
||||
Верхняя грань площадки «продолжалась» за её краем и держала игрока в воздухе
|
||||
навечно — «зависание над краем». Полупространство ограничивает **плоскость**,
|
||||
а не сам кирпич, поэтому одного раздутия граней мало.
|
||||
|
||||
Починка: swept-bounds early-out — грань отбрасывается, если swept-габарит тела
|
||||
заведомо не пересекает кирпич.
|
||||
|
||||
### B7.9. Один общий процент шага вместо цикла касаний
|
||||
|
||||
Движение было одним общим `fraction` на всю траекторию: первое же касание
|
||||
останавливало весь шаг, и игрок «запинкой» полз по полу.
|
||||
|
||||
Заменено на цикл касаний по образцу `TryPlayerMove`: `MOVE_BUMPS = 4` попыток,
|
||||
каждая — отдельное отсечение от всех кирпичей; касание гасит компоненту
|
||||
скорости, упирающуюся в контакт, и цикл продолжает по оставшейся скорости.
|
||||
|
||||
### B7.10. Подшаг всегда выбирал путь «вверх», а он уводил назад
|
||||
|
||||
`StepMove` в оригинале пробует оба пути и **побеждает тот, что ушёл дальше по
|
||||
горизонтали** (`if (flDownDist > flUpDist)` — иначе подъём). Наша версия брала
|
||||
подъём всегда, если он вообще не был заблокирован.
|
||||
|
||||
На крутой рампе путь «вверх-вперёд-вниз» уводит игрока НАЗАД по склону.
|
||||
Замер: игрок сползал по 45° рампе с x = -256 до x = -1066 за 5 секунд.
|
||||
|
||||
Переписано по оригиналу, включая `m_vecVelocity.z = vecDownVel.z` при победе
|
||||
подъёма — иначе на рампе появляется паразитная вертикальная составляющая.
|
||||
|
||||
### B7.11. `StayOnGround` падал всем шагом и выталкивал вдоль нормали
|
||||
|
||||
Тело, стоящее на рампе, чуть **внутри** раздутой полуплоскости (угол AABB ниже
|
||||
поверхности), и отсечение его не держит. Упав на всю высоту шага, тело
|
||||
вминалось в кирпич, и разрешение выталкивало его вдоль нормали — то есть
|
||||
**вбок по склону**. Это и был сползающий игрок.
|
||||
|
||||
Починка: зондирование вниз мелким шагом (1 юнит, до `STEP_HEIGHT` шагов)
|
||||
вместо одного падения. Вминание ограничено той же величиной.
|
||||
|
||||
### B7.12. Трение должно быть всегда на земле
|
||||
|
||||
`FullWalkMove` в Source на земле обнуляет вертикальную скорость и **безусловно**
|
||||
зовёт `Friction()` — при любом вводе, а не только при его отсутствии. Именно
|
||||
оно удерживает игрока на рампе. У нас трение было только без ввода.
|
||||
|
||||
### B7.13. Линейная интерполяция ускорения несовместима с трением
|
||||
|
||||
`v += (wish - v) * accel * dt` замедляет не хуже, чем ускоряет: равновесие
|
||||
устанавливается на **179 юнитах/с** вместо 250 (замерено на gm_construct). Это
|
||||
и была жалоба «хожу медленно».
|
||||
|
||||
Заменено на квото-формулу Source `Accelerate` — аддитивную:
|
||||
|
||||
```text
|
||||
addspeed = wishspd - dot(velocity, wishdir)
|
||||
accelspeed = min(accel * dt * wishspd, addspeed)
|
||||
velocity += wishdir * accelspeed
|
||||
```
|
||||
|
||||
Она снимает ровно столько, сколько прибавляет трение, поэтому игрок выходит на
|
||||
полную скорость. Ограничение `sv_air_max_wishspeed = 30` действует **только в
|
||||
воздухе** (`AirAccelerate`, `GetAirSpeedCap()`); на земле ограничения нет.
|
||||
|
||||
### B7.14. Замер на настоящей карте
|
||||
|
||||
Тест `real_map_movement` (`--ignored`, путь через `SOURCE_RS_TEST_BSP`):
|
||||
спавн gm_construct (704, 132, -143).
|
||||
|
||||
| | жалоба | было | стало | в Source |
|
||||
|---|---|---|---|---|
|
||||
| Скорость ходьбы | «хожу медленно» | 165 | **224,9** (среднее за 0,5 с с разгона) | 250 |
|
||||
| Дёрганье назад | «кринит и запинаюсь» | — | **0,00** юнита за 3 с | 0 |
|
||||
| Высота прыжка | «прыгать не реально» | не отрывался | **42,7** | ~45 |
|
||||
| Падение сквозь мир | «падаю сквозь карту» | проваливался | не проваливается | не проваливается |
|
||||
| Проход сквозь рампу | «сквозь рампу прошёл» | 87% кирпичей нет объёма | не проходит | не проходит |
|
||||
|
||||
Скорость 224,9 — среднее за первые полсекунды с разгона с нуля; на установившемся
|
||||
ходе она выходит на 250.
|
||||
|
||||
Старый тест `real_map_player_rests_walks_and_does_not_sink` требовал пройти
|
||||
2000 юнитов вслепую вдоль +x, но на gm_construct там стена на x = 992 (288
|
||||
юнитов от спавна). Ожидание теста было неверно, а не игрок; тест удалён, его
|
||||
содержательно корректная часть покрыта `real_map_movement`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue