From 512f3d039490d974575e8bc94ddb6dcd00cde0a8 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Wed, 14 Oct 2026 07:07:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=20CGameMovement:=20SAT,=20=D1=86=D0=B8=D0=BA=D0=BB=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=81=D0=B0=D0=BD=D0=B8=D0=B9,=20=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D1=82=D0=BE-=D1=83=D1=81=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замеры на 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 чист. --- crates/engine/src/player.rs | 798 ++++++++++++++++++++++++++---------- docs/PARITY.md | 119 +++++- 2 files changed, 688 insertions(+), 229 deletions(-) diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index 9b3ea0e..eeb8a2c 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -96,11 +96,21 @@ impl Brush { /// поэтому хватает пересечений по парам. Нормали из BSP не единичные, но /// формула ниже работает и для них: масштаб нормали и `d` взаимно /// сокращается. + /// Пересечение ровно двух граней даёт **прямую**, а не точку. Если взять + /// ближайшую к началу координат точку на ней, она окажется вне кирпича и + /// будет отброшена: у ящика из шести граней так не находилось ни одной + /// вершины, и на gm_construct 1846 кирпичей из 2126 (87%) считались + /// «без объёма», то есть не твёрдыми вовсе — сквозь них можно было + /// пройти, стоять на них было не на чем. + /// + /// Поэтому по каждой паре непараллельных граней строится сама прямая, + /// а затем обрезается всеми полупространствами: концы отрезка и есть + /// вершины. После починки «без объёма» осталось 3 кирпича из 2126. fn vertices_of(planes: &[(Vec3, f32)]) -> Vec { // У кирпича больше 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 = 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 = 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 = 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, + 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, 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 + ); + } } diff --git a/docs/PARITY.md b/docs/PARITY.md index 215b9cf..b44d0a7 100644 --- a/docs/PARITY.md +++ b/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`.