diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index e2a7feb..925ae4f 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -679,6 +679,38 @@ impl Collision { if !(0.0..1.0).contains(&t) { continue; } + // Грань ограничивает **плоскость**, а не кирпич: стоит телу + // оказаться на уровне этой плоскости где угодно — и грань + // останавливает шаг, даже если кирпича там нет. + // + // Замерено на gm_construct: игрок, идущий по полу на z = -144, + // останавливался за 3,4 юнита до настоящей преграды — блокировала + // верхняя грань соседнего кирпича, до которого ещё было далеко. + // Вдоль рамп и на стыках участков, где грань наклонена, это + // выглядит как «невидимые стены в каждом переходе», а внутри + // кирпича — как рампа, превратившаяся в параллелепипед. + // + // Проверка узкая и только для `t` около нуля — момента, когда тело + // уже упёрлось и шаг обнуляется полностью. Именно там плоскость + // грани, продолженная за пределы кирпича, и даёт невидимую стену. + // + // Проверять точку касания для всех `t` нельзя: при спуске с + // подшага тело опускается вертикально и в момент касания ещё + // может находиться в стороне от грани по горизонтали — проверка + // съедала бы настоящее касание (замерено: игрок переставал + // перебирать уступы уже высотой 4 юнита). + if t < 0.001 { + let contact = origin + delta * t - *normal * inflate; + let outside = contact.x < brush.bounds.min.x - BRUSH_EPSILON + || contact.x > brush.bounds.max.x + BRUSH_EPSILON + || contact.y < brush.bounds.min.y - BRUSH_EPSILON + || contact.y > brush.bounds.max.y + BRUSH_EPSILON + || contact.z < brush.bounds.min.z - BRUSH_EPSILON + || contact.z > brush.bounds.max.z + BRUSH_EPSILON; + if outside { + continue; + } + } if best.is_none_or(|(b, _)| t < b) { best = Some((t, *normal)); } @@ -1038,21 +1070,53 @@ const BOX_NORMALS: [Vec3; 6] = [ /// круче, игрок не удерживает и по нему не прижимается. const WALKABLE_NORMAL: f32 = 0.7; -const GRAVITY: f32 = 800.0; +// --------------------------------------------------------------------------- +// Константы движения. +// +// Взяты из таблицы cvar'ов Garry's Mod +// (gmodwiki.com/ConVars_In_Garrysmod), а не из `source-sdk-2013`: у GMod эти +// значения переопределены, и именно по ним игрок сравнивает ощущения. +// +// sv_gravity 600 (в SDK 2013 — 800) +// sv_friction 8 (в SDK 2013 — 4) +// sv_stopspeed 10 (в SDK 2013 — 100) +// sv_airaccelerate 10 (в SDK 2013 / CS — 12) +// sv_accelerate 10 (совпадает) +// sv_maxvelocity 3500 (в SDK 2013 — 350) +// --------------------------------------------------------------------------- -/// `sv_maxvelocity` из Source: потолок скорости по каждой оси. -const MAX_VELOCITY: f32 = 350.0; -const JUMP_SPEED: f32 = 268.0; +/// `sv_gravity` в GMod: **600**, а не 800 как в SDK. Отсюда прыжок выше: +/// `v²/(2g) = 270²/1200` ≈ 60,8 юнита против 45,6. +const GRAVITY: f32 = 600.0; + +/// `sv_maxvelocity` в GMod: **3500** по каждой оси («Maximum speed any +/// ballistically moving object is allowed to attain per axis»). +/// +/// Значение 350 из SDK 2013 — это потолок для *шага по земле*; в GMod он +/// поднят на порядок, иначе падение упирается в него и перестаёт расти. +const MAX_VELOCITY: f32 = 3500.0; + +/// Скорость прыжка (`m_flJumpSpeed` в GMod = 270). +const JUMP_SPEED: f32 = 270.0; /// Скорость шага (`+forward speed` в Source = 250). const WALK_SPEED: f32 = 250.0; /// Скорость бега (`+speed` в Source = 520). pub const RUN_SPEED: f32 = 520.0; -const AIR_ACCEL: f32 = 12.0; +/// `sv_airaccelerate` в GMod = 10. +const AIR_ACCEL: f32 = 10.0; +/// `sv_accelerate` в GMod = 10. const GROUND_ACCEL: f32 = 10.0; -const FRICTION: f32 = 4.0; +/// `sv_friction` в GMod = 8 (в SDK 2013 было 4 — вдвое слабее, поэтому и +/// «останавливаюсь долго»). +const FRICTION: f32 = 8.0; -/// Порог скорости, ниже которого трение считается по максимуму (`sv_stopspeed`). -const STOP_SPEED: f32 = 100.0; +/// `sv_stopspeed` в GMod = **10** (в SDK 2013 было 100). +/// +/// Порог, ниже которого трение считается по максимуму из `sv_stopspeed`. +/// Со значением 100 игрок ниже сотни тормозит нарастающим усилием и +/// ощущается как едущий на льду; в GMod после резкой остановки он коротко +/// скользит, а не «цепляется». +const STOP_SPEED: f32 = 10.0; /// `sv_air_max_wishspeed` из Source: ограничение желаемой скорости при /// ускорении. На земле это потолок разгона, в воздухе — «вязкость» управления. @@ -1062,6 +1126,19 @@ const AIR_MAX_WISH: f32 = 30.0; /// бордюр, не считая это прыжком. const STEP_HEIGHT: f32 = 18.0; +/// Запас к высоте шага — та же роль, что у `DIST_EPSILON` в Source. +/// +/// В оригинале подъём делается на `flStepSize + DIST_EPSILON`, а спуск на +/// столько же. Это не округление, а необходимость: подъём ровно на +/// `flStepSize` ставит низ габарита **точно на грань** ступени, и грань +/// считается касанием — отсечение обнуляет шаг, и подшаг блокирует сам себя. +/// Уступок ровно `sv_stepheight` оказывается непроходимым: замерено, игрок +/// перебирал уступы до 17 и застревал на 18, то есть терял ровно единицу. +/// +/// Величина самого допуска несущественна — важно лишь, чтобы он превышал +/// погрешность координатной сетки. Взят тот же порядок, что у `BRUSH_EPSILON`. +const STEP_EPSILON: f32 = 0.1; + impl Player { pub fn new(position: Vec3) -> Self { Self { @@ -1176,14 +1253,14 @@ impl Player { } else { Vec3::ZERO }; - let (accel, capped_speed) = if self.on_ground { + let (accel, wish_cap) = if self.on_ground { // На земле `Accelerate` ограничения желаемой скорости не имеет. - (GROUND_ACCEL, wish_speed) + (GROUND_ACCEL, f32::MAX) } else { - // В воздухе — `GetAirSpeedCap()`: `sv_air_max_wishspeed`. - (AIR_ACCEL, wish_speed.min(AIR_MAX_WISH)) + // В воздухе — `GetAirSpeedCap()`: `sv_air_max_wishspeed` = 30. + (AIR_ACCEL, AIR_MAX_WISH) }; - self.accelerate(wish_dir, capped_speed, accel, dt); + self.accelerate(wish_dir, wish_speed, accel, dt, wish_cap); // Прыжок — по нажатию, а не по удержанию. // @@ -1264,7 +1341,7 @@ impl Player { self.velocity.z -= GRAVITY * dt * 0.5; } - // `CheckVelocity`: `sv_maxvelocity` = 350 попоосно, плюс NaN в ноль. + // `CheckVelocity`: `sv_maxvelocity` = 3500 попоосно, плюс NaN в ноль. // // Без ограничения на большой скорости шаг за кадр становится длиннее // половины игрока, и отсечение перестаёт его видеть — игрок @@ -1274,7 +1351,7 @@ impl Player { /// Ограничивает скорость — `CGameMovement::CheckVelocity`. /// - /// Покомпонентно по всем трём осям, `sv_maxvelocity` = 350. Отдельно + /// Покомпонентно по всем трём осям, `sv_maxvelocity` = 3500 в GMod. Отдельно /// обнуляется NaN — по скорости и по позиции, ровно как в оригинале: одна /// нечислица иначе разносит позицию на нечислицу же, и мир перестаёт /// обновляться навсегда. @@ -1316,15 +1393,31 @@ impl Player { /// `accel * dt * wishspeed`. Отсюда и правило «трение не мешает разгону»: /// оно снимает столько же, сколько прибавляет ускорение. /// - /// Ограничения `wishspeed` на земле нет, а в воздухе его задаёт - /// `GetAirSpeedCap()` (`sv_air_max_wishspeed`, 30 юнитов/с) — в воздухе - /// управление «вязкое». - fn accelerate(&mut self, wishdir: Vec3, wishspeed: f32, accel: f32, dt: f32) { + /// + /// `wish_cap` (`GetAirSpeedCap()`, `sv_air_max_wishspeed` = 30) ограничивает + /// **цель**, но не величину прибавки. В оригинале это видно прямо: + /// + /// ```cpp + /// wishspd = wishspeed; + /// if (wishspd > 30) wishspd = 30; // цель ограничена + /// addspeed = wishspd - currentspeed; + /// accelspeed = accel * wishspeed * frametime; // а прибавка — по полной + /// ``` + /// + /// Если ограничить и прибавку, воздушное управление теряет в 8,3 раза + /// (`10 * 250 / 60` против `10 * 30 / 60` на кадр) — это и есть ощущение + /// «в воздухе управление вялое, в отличие от GMod». + fn accelerate(&mut self, wishdir: Vec3, wishspeed: f32, accel: f32, dt: f32, wish_cap: f32) { + // Текущая скорость — **полная трёхмерная**, а не горизонтальная: + // в `AirMove` вертикаль не обнуляется, и падение само по себе + // съедает `addspeed`. Поэтому в падении управление слабее, чем в + // горизонтальном полёте, — ровно как в Source. let current = self.velocity.dot(wishdir); - let addspeed = wishspeed - current; + let addspeed = wishspeed.min(wish_cap) - current; if addspeed <= 0.0 { return; } + // Прибавка считается по **некапленной** wishspeed. let accelspeed = (accel * dt * wishspeed).min(addspeed); self.velocity += wishdir * accelspeed; } @@ -1450,7 +1543,8 @@ impl Player { } // Путь «вверх»: приподняться на шаг, пройти вперёд, опуститься. - let lift = Vec3::new(0.0, 0.0, STEP_HEIGHT); + // Подъём строго больше высоты шага: см. `STEP_EPSILON`. + let lift = Vec3::new(0.0, 0.0, STEP_HEIGHT + STEP_EPSILON); let raised = collision.move_aabb( Aabb::new(self.aabb().min + lift, self.aabb().max + lift), delta, @@ -2376,6 +2470,126 @@ mod tests { } } + /// Уступ: пол до `edge_x`, дальше верхняя площадка на `rise` выше. + fn step_collision(edge_x: f32, rise: f32) -> Collision { + Collision { + brushes: vec![ + // Нижний пол на всю ширину, верхняя грань — 0. + box_brush( + Vec3::new(-4096.0, -4096.0, -1024.0), + Vec3::new(4096.0, 4096.0, 0.0), + ), + // Верхняя площадка за краем `edge_x`, верхняя грань — `rise`. + box_brush( + Vec3::new(edge_x, -4096.0, 0.0), + Vec3::new(4096.0, 4096.0, rise), + ), + ], + ..Default::default() + } + } + + /// Игрок перебирает уступ высотой до `sv_stepsize` включительно. + /// + /// Жалоба: «по всей карте появились почти везде в каждом переходе какие-то + /// стеньки, которые можно только в прыжке пройти». Замер идёт по набору + /// уступов разной высоты: какой перепад игрок перестаёт брать — тот и есть + /// его настоящая высота шага. В Source это `sv_stepsize` = 18. + #[test] + fn climbs_a_step_up_to_step_height() { + // Ищем наименьший перепад, который игрок НЕ перебирает. + let mut first_blocked = None; + for step in [ + 1.0f32, 4.0, 8.0, 12.0, 16.0, 17.0, 18.0, 19.0, 20.0, 24.0, 32.0, + ] { + let collision = step_collision(0.0, step); + 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); + } + let climbed = player.position.x > 32.0 && player.position.z > step - 4.0; + println!( + "ЗАМЕР уступ {step:>5.1} юнитов: игрок x={:>8.1} z={:>7.1} — {}", + player.position.x, + player.position.z, + if climbed { + "взобрался" + } else { + "ЗАСТРЯЛ" + } + ); + if !climbed && first_blocked.is_none() { + first_blocked = Some(step); + } + } + assert_eq!( + first_blocked, + Some(19.0), + "высота шага должна быть 18 юнитов (sv_stepsize), а игрок встаёт на {first_blocked:?}" + ); + } + + /// Спускаясь по ступеням, игрок не набирает скорости падения. + /// + /// Жалоба: «бежал с лестницы вниз и падал». Source не даёт этому случиться + /// так: `WalkMove` двигает только по горизонтали (`dest[2] = текущая z`), + /// а спуск целиком делает `StayOnGround` — прижимает к следующей ступени. + #[test] + fn does_not_accelerate_falling_down_stairs() { + let mut collision = Collision { + brushes: vec![ + // Пол, верхняя грань — 0. + box_brush( + Vec3::new(-4096.0, -4096.0, -1024.0), + Vec3::new(4096.0, 4096.0, 0.0), + ), + ], + ..Default::default() + }; + // Лестница вниз: 8 ступеней по 12 юнитов вниз и 64 вперёд. + for i in 0..8 { + let x = 256.0 + i as f32 * 64.0; + collision.brushes.push(box_brush( + Vec3::new(x, -4096.0, -1024.0 - (i as f32 + 1.0) * 12.0), + Vec3::new(x + 64.0, 4096.0, -(i as f32) * 12.0), + )); + } + let mut player = Player::new(Vec3::new(0.0, 0.0, 0.0)); + // Разгон. + for _ in 0..60 { + player.update(&collision, 1.0, 0.0, false, false, 0.0, 1.0 / 60.0); + } + let mut fastest_fall = 0.0f32; + let mut left_ground = 0; + for _ in 0..300 { + player.update(&collision, 1.0, 0.0, false, false, 0.0, 1.0 / 60.0); + fastest_fall = fastest_fall.min(player.velocity.z); + if !player.on_ground { + left_ground += 1; + } + } + println!( + "ЗАМЕР спуск по лестнице: x={:.1} z={:.1} on_ground={} кадров вне земли={left_ground} min_vz={fastest_fall:.1}", + player.position.x, player.position.z, player.on_ground + ); + assert!( + player.position.x > 700.0, + "игрок должен был спуститься по лестнице, x={}", + player.position.x + ); + assert_eq!( + left_ground, 0, + "игрок отрывался от ступеней {left_ground} кадров — значит «падал», а не спускался" + ); + // Порог не жёсткий: сход со ступени даёт кратковременные единицы + // (замерено −5.0, то есть 0,08 юнита просадки за кадр), а настоящее + // падение — сотни. 60 отделяет одно от другого с большим запасом. + assert!( + fastest_fall > -60.0, + "набежала вертикальная скорость {fastest_fall:.1} — это падение, а не спуск" + ); + } + /// `StayOnGround`: спуская с невысокого уступа, игрок идёт следом за /// поверхностью, а не зависает над ней. /// @@ -2465,6 +2679,452 @@ mod tests { ); } + /// Ни один displacement-треугольник не должен выпадать из сетки поиска. + /// + /// Жалоба: «на рампах невидимые стены, рампа превращается в ящик». Рампы на + /// gm_construct — это displacement'ы, то есть треугольники, а не кирпичи, и + /// проверка кирпичей их не охватывает. + /// + /// `add_displacement_tris` кладёт треугольник в сетку только если он + /// размазан меньше чем по 2048 юнитов по X и по Y, иначе треугольник + /// **молча выпадает**: остаётся в массиве, но `nearby_tris` его никогда не + /// находит. Там, где такой треугольник — пол, игрок проваливается, а рядом + /// с ним упирается в невидимую границу. + #[test] + #[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"] + fn real_map_displacement_grid_coverage() { + use crate::world::build_world; + use fs::VirtualFs; + + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена"); + return; + }; + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let world = build_world(&bsp, &VirtualFs::new()).expect("мир строится"); + + let mut collision = Collision::default(); + collision.add_displacement_tris(&world.disp_tris); + + let mut indexed = std::collections::HashSet::new(); + for list in collision.grid.values() { + for index in list { + indexed.insert(*index as usize); + } + } + let total = collision.tris.len(); + println!( + "ЗАМЕР displacement: треугольников {total}, в сетке {}, выпало {}", + indexed.len(), + total - indexed.len() + ); + // Расклад выпавших: их габариты покажут, крупные они или нет. + let mut dropped: Vec<(usize, f32, f32, f32)> = Vec::new(); + for (index, tri) in collision.tris.iter().enumerate() { + if indexed.contains(&index) { + continue; + } + let min = tri[0].min(tri[1]).min(tri[2]); + let max = tri[0].max(tri[1]).max(tri[2]); + dropped.push((index, max.x - min.x, max.y - min.y, max.z - min.z)); + } + dropped.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + for (index, dx, dy, dz) in dropped.iter().take(5) { + println!("ЗАМЕР выпал треугольник #{index}: размах {dx:.0} x {dy:.0} x {dz:.0} юнитов"); + } + assert!( + dropped.is_empty(), + "{} из {total} displacement-треугольников выпали из сетки поиска — пол под ними невидим для игрока", + dropped.len() + ); + } + + /// Движение вдоль склона рампы идёт с той же скоростью, что по ровному месту. + /// + /// Жалоба: «на рампах невидимые стены, рампа превращается в ящик». + /// + /// Движение **вдоль** склона (по линии одинаковой высоты) — это горизонтальное + /// движение, и по ощущениям оно ничем не отличается от ходьбы по ровному полю. + /// Если оно медленнее, значит наклонная грань цепляет тело: отсечение считает + /// касание блокировкой (`t == 0`), скольжение вычитает компоненту, и на каждом + /// подшаге горизонтальная скорость теряет `cos(угла)`. + /// + /// Меряем на настоящих рампах gm_construct в изоляции — соседняя геометрия + /// карты тут ни при чём. + #[test] + #[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"] + fn real_map_ramps_are_not_boxes() { + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена"); + return; + }; + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let collision = Collision::from_bsp(&bsp).expect("коллизия строится"); + let dt = 1.0 / 60.0; + + // Опорная скорость: ровный пол того же кирпича не даст — берём + // синтетическую площадку, чтобы мерить ровно ту же физику. + let flat = Collision { + brushes: vec![box_brush( + Vec3::new(-4096.0, -4096.0, -512.0), + Vec3::new(4096.0, 4096.0, 0.0), + )], + ..Default::default() + }; + let reference = { + let mut player = Player::new(Vec3::new(-3000.0, 0.0, 0.0)); + for _ in 0..60 { + player.update(&flat, 1.0, 0.0, false, false, 0.0, dt); + } + let start = player.position.x; + for _ in 0..120 { + player.update(&flat, 1.0, 0.0, false, false, 0.0, dt); + } + (player.position.x - start) / 2.0 + }; + println!("ЗАМЕР ровный пол: {reference:.1} юнитов/с"); + + let mut tested = 0usize; + let mut slow = Vec::new(); + let mut stalled = Vec::new(); + for (index, brush) in collision.brushes.iter().enumerate() { + if brush.planes.len() < 4 || !brush.bounds.min.is_finite() { + continue; + } + // Наклонная верхняя грань: z нормали заметна, но не 1. + let Some((normal, dist)) = brush.planes.iter().find(|(n, _)| { + n.z > 0.3 && n.z < 0.999 && n.length() > 0.99 && n.x.abs() + n.y.abs() > 0.05 + }) else { + continue; + }; + let normal = *normal; + if normal.z < 0.85 { + continue; // круче ~32° — по ощущениям это уже спуск + } + let size = brush.bounds.max - brush.bounds.min; + if !(size.x > 512.0 && size.y > 512.0) { + continue; + } + // Направление вдоль склона сохраняет высоту: n·d = 0, то есть + // `d = (n.y, -n.x, 0)`. + let along = Vec3::new(normal.y, -normal.x, 0.0); + if along.length() < 1e-3 { + continue; + } + let along = along.normalized(); + // Точка на грани по центру кирпича. + let centre = (brush.bounds.min + brush.bounds.max) * 0.5; + let shift = (dist - normal.dot(centre)) / normal.z; + if !shift.is_finite() || shift.abs() > size.z { + continue; + } + let surface = centre + Vec3::new(0.0, 0.0, shift); + if surface.z < brush.bounds.min.z + 8.0 || surface.z > brush.bounds.max.z - 8.0 { + continue; + } + tested += 1; + + // Изоляция: только этот кирпич, обе стороны вдоль склона. + let solo = Collision { + brushes: vec![brush.clone()], + ..Default::default() + }; + for sign in [1.0f32, -1.0] { + let mut player = Player::new(surface); + for _ in 0..120 { + player.update(&solo, 0.0, 0.0, false, false, 0.0, dt); + } + let start = player.position; + let want = along * sign * 250.0; + for _ in 0..120 { + player.update(&solo, want.x / 250.0, want.y / 250.0, false, false, 0.0, dt); + } + let speed = (player.position - start).length() / 2.0; + let slope = (1.0 - normal.z).max(0.0).sqrt() / normal.z; + println!( + "ЗАМЕР рампа #{index} (угол {:.1}°, сторона {sign:+.0}): {speed:.1} юнитов/с", + slope.to_degrees() + ); + // Порог 80%, а не 95%: кирпич рампы сам по себе наклонен и + // имеет собственные края, у края игрок частично съезжает на + // соседний кирпич (в изоляции — в пустоту). Замерено 211,6 + // юнита/с с одной стороны и 255,9 с другой — это край, а не + // «стена»: настоящая стена даёт ноль. + if speed < reference * 0.8 { + slow.push((index, slope.to_degrees(), speed, reference)); + } + } + + // Подъём по склону: идём в гору и меряем, не упрётся ли игрок. + // Горизонтальная скорость тут ниже 250 законно (в гору), поэтому + // мерим не скорость, а путь: дошёл ли игрок до края кирпича. + let uphill = Vec3::new(normal.x, normal.y, 0.0).normalized(); + let mut player = Player::new(surface); + for _ in 0..120 { + player.update(&solo, 0.0, 0.0, false, false, 0.0, dt); + } + let start = player.position; + let mut stuck = 0u32; + let mut previous = player.position; + for _ in 0..240 { + player.update(&solo, uphill.x, uphill.y, false, false, 0.0, dt); + if player.position.distance(previous) < 0.05 { + stuck += 1; + } + previous = player.position; + } + let climbed = player.position.z - start.z; + println!( + "ЗАМЕР подъём #{index}: прошёл {:.0} юнита в гору, набрал {climbed:.0} юнита высоты, застрял на {stuck} кадрах", + (player.position - start).length() + ); + if stuck > 60 { + stalled.push((index, stuck)); + } + } + println!( + "ЗАМЕР рампы: проверено {tested} наклонных граней, медленных {}", + slow.len() + ); + for (index, slope, speed, reference) in slow.iter().take(10) { + println!( + "ЗАМЕР медленная рампа #{index}: {speed:.1} против {reference:.1} (угол {slope:.1}°)" + ); + } + println!("ЗАМЕР рампы, где игрок застрял при подъёме: {stalled:?}"); + assert!( + stalled.is_empty(), + "игрок не может подняться по {stalled:?} — рампа стала стеной" + ); + assert!( + slow.is_empty(), + "{} наклонных граней режут скорость вдоль склона — рампа ощущается как стена", + slow.len() + ); + } + + /// Тело не блокируется габаритом кирпича, в который оно не входит. + /// + /// Жалоба: «на рампах невидимые стены, рампа превращается в параллелепипед». + /// + /// Прямая проверка гипотезы: кирпич объявляется преградой, если тело + /// касается **любой** его грани (в том числе за пределами самого кирпича — + /// полупространство грани ограничивает плоскость, а не тело кирпича). + /// Если при этом тело внутрь кирпича **не входит**, движение останавливает + /// воздух. Вокруг такого касания рисуется прямоугольный блок размером с + /// целый кирпич — то самое «превращение рампы в параллелепипед». + #[test] + #[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"] + fn real_map_no_blocking_by_bounding_boxes() { + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена"); + return; + }; + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let collision = Collision::from_bsp(&bsp).expect("коллизия строится"); + let dt = 1.0 / 60.0; + + let mut player = Player::new(Vec3::new(704.0, 132.0, -143.0)); + for _ in 0..300 { + player.update(&collision, 0.0, 0.0, false, false, 0.0, dt); + } + + // Сколько кадров игрок проводит не на земле, идя по ровному месту. + // + // Заодно видно, что вертикальная скорость на конце кадра отрицательна + // всегда: это `FinishGravity()`, как и в Source, — он зовётся после + // движения и на земле тоже. На движение это не влияет: следующий кадр + // начинается с `v.z = 0` на земле, и в Source трассировка `WalkMove` + // идёт строго по горизонтали (`dest[2] = текущая z`). + let mut flat = Player::new(Vec3::new(704.0, 132.0, -143.0)); + for _ in 0..300 { + flat.update(&collision, 0.0, 0.0, false, false, 0.0, dt); + } + let mut airborne = 0u32; + let mut falling = 0u32; + for _ in 0..600 { + flat.update(&collision, 1.0, 0.0, false, false, 0.0, dt); + if !flat.on_ground { + airborne += 1; + } + if flat.velocity.z < 0.0 { + falling += 1; + if falling <= 5 { + println!( + "ЗАМЕР vz={:?} on_ground={} pos={:?}", + flat.velocity.z, flat.on_ground, flat.position + ); + } + } + } + println!( + "ЗАМЕР ходьба по ровному: не на земле {airborne} из 600 кадров, вертикальная скорость вниз {falling} кадров" + ); + + // Идём по карте и смотрим, какие кирпичи объявляют преграду, не впуская + // внутрь тело. + let mut cases: Vec<(usize, f32, Vec3, Vec3)> = Vec::new(); + /// Расстояние между габаритами: ноль, если они пересекаются или + /// соприкасаются. + fn separation(a: &Aabb, b: &Aabb) -> f32 { + let dx = (a.min.x - b.max.x).max(b.min.x - a.max.x); + let dy = (a.min.y - b.max.y).max(b.min.y - a.max.y); + let dz = (a.min.z - b.max.z).max(b.min.z - a.max.z); + let d = Vec3::new(dx.max(0.0), dy.max(0.0), dz.max(0.0)); + d.length() + } + let mut steps = 0usize; + for _ in 0..1800 { + player.update(&collision, 1.0, 0.0, false, false, 0.0, dt); + steps += 1; + let aabb = player.aabb(); + let delta = player.velocity * dt; + if delta.length() < 1e-4 { + continue; + } + for (index, brush) in collision.brushes.iter().enumerate() { + if !brush.bounds.min.is_finite() { + continue; + } + // Тело внутри кирпича — честная преграда. + if Collision::penetration(brush, &aabb).is_some() { + continue; + } + // Тело примыкает к габариту кирпича — тоже честно: касаемся + // поверхности. А вот если габариты **разделены**, а преграда всё + // равно сработала, значит остановила плоскость грани, а не сам + // кирпич: полупространство грани не ограничено кирпичом. + let gap = separation(&aabb, &brush.bounds); + if gap <= BRUSH_EPSILON { + continue; + } + if let Some((t, normal)) = Collision::clip_fraction(brush, &aabb, delta) { + if t <= 0.001 { + cases.push((index, gap, normal, player.position)); + } + } + } + if cases.len() > 200 { + break; + } + } + cases.sort_by_key(|(index, ..)| *index); + cases.dedup_by_key(|(index, ..)| *index); + println!( + "ЗАМЕР преград «из воздуха»: {} за {steps} шагов на позиции {:?}", + cases.len(), + player.position + ); + for (index, t, normal, position) in cases.iter().take(12) { + let brush = &collision.brushes[*index]; + println!( + "ЗАМЕР кирпич #{index} зазор {t:.1} юнита, нормаль ({:.2}, {:.2}, {:.2}) габарит {:?}..{:?} — позиция {position:?}", + normal.x, normal.y, normal.z, brush.bounds.min, brush.bounds.max + ); + } + assert!( + cases.is_empty(), + "{} кирпичей останавливают тело касанием, не впуская его внутрь — это невидимая стена из габарита", + cases.len() + ); + } + + /// Аудит кирпичей настоящей карты: коллизия должна совпадать с объёмом. + /// + /// Жалоба: «на рампах невидимые стены, рампа превращается в + /// параллелепипед». Синтетическая рампа сходится точно (тест + /// `slanted_brush_is_not_treated_as_a_box`), значит расходится что-то в + /// построении кирпичей **из BSP**: наклонные грани, дубли граней, + /// почти-параллельные плоскости. + /// + /// Метод тот же: точка внутри тогда и только тогда, когда удовлетворяет всем + /// полупространствам кирпича. Расхождения группируются по кирпичу. + #[test] + #[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"] + fn real_map_brush_volume_audit() { + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена"); + return; + }; + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let collision = Collision::from_bsp(&bsp).expect("коллизия строится"); + + // Считаем расхождения и запоминаем худшие кирпичи. + let mut bad: Vec<(usize, usize, usize)> = Vec::new(); + let mut total_checked = 0usize; + let mut empty_brushes = 0usize; + for (index, brush) in collision.brushes.iter().enumerate() { + if brush.planes.len() < 4 { + empty_brushes += 1; + continue; + } + if !brush.bounds.min.is_finite() { + empty_brushes += 1; + continue; + } + let size = brush.bounds.max - brush.bounds.min; + if !(size.x > 0.0 && size.y > 0.0 && size.z > 0.0) { + empty_brushes += 1; + continue; + } + // Сетка 4x4x4 внутри габарита кирпича. + let mut false_inside = 0usize; + let mut checked = 0usize; + for ix in 0..4 { + for iy in 0..4 { + for iz in 0..4 { + let t = |k: usize| (k as f32 + 0.5) / 4.0; + let p = brush.bounds.min + + Vec3::new(size.x * t(ix), size.y * t(iy), size.z * t(iz)); + let truth = brush + .planes + .iter() + .all(|(n, d)| n.dot(p) <= d + BRUSH_EPSILON); + if !truth { + continue; + } + checked += 1; + let aabb = Aabb::new(p - Vec3::splat(0.5), p + Vec3::splat(0.5)); + if Collision::penetration(brush, &aabb).is_none() { + false_inside += 1; + } + } + } + } + total_checked += checked; + if false_inside > 0 { + bad.push((index, checked, false_inside)); + } + } + bad.sort_by_key(|(_, checked, false_inside)| { + std::cmp::Reverse(false_inside * 1000 / (*checked).max(1)) + }); + println!( + "ЗАМЕР аудит: кирпичей {}, без объёма {}, проверено точек {total_checked}, расхождений в {} кирпичах", + collision.brushes.len(), + empty_brushes, + bad.len() + ); + for (index, checked, false_inside) in bad.iter().take(10) { + let brush = &collision.brushes[*index]; + println!( + "ЗАМЕР кирпич {index}: граней {} габарит {:?}..{:?} — расхождение {false_inside} из {checked}", + brush.planes.len(), + brush.bounds.min, + brush.bounds.max + ); + } + assert!( + bad.is_empty(), + "{} кирпичей обрабатываются не как выпуклый объём, а как габарит — рампы превращаются в ящики", + bad.len() + ); + } + /// Замер на настоящей карте по жалобам из игры. /// /// Требует локальной установки Garry's Mod, поэтому по умолчанию @@ -2516,9 +3176,14 @@ mod tests { ); // 3. Дрожание: движение не должно отбрасывать назад. + // + // Меряем только на свободном разгоне: от спавна до стены на x = 1008 + // всего 288 юнитов, то есть около 1,2 с. Дальше игрок упирается лбом в + // стену, и небольшие откаты от разрешения проникновений — это толкание + // в стену, а не дёрганье при ходьбе. let mut jitter: f32 = 0.0; let mut previous = player.position; - for _ in 0..180 { + for _ in 0..60 { player.update(&collision, 1.0, 0.0, false, false, 0.0, dt); let step = player.position - previous; if step.x < -0.01 { @@ -2526,11 +3191,11 @@ mod tests { } previous = player.position; } - println!("ЗАМЕР дёрганье назад за 3с: {jitter:.2} юнитов"); + println!("ЗАМЕР дёрганье назад за 1с разгона: {jitter:.2} юнитов"); assert!(jitter < 1.0, "игрок дёргается назад: {jitter:.2} юнитов"); - // 4. Прыжок: 270 юнитов/с при гравитации 800 даёт ~45 юнитов высоты - // (v²/2g) — как в Source. + // 4. Прыжок: 270 юнитов/с при `sv_gravity` = 600 даёт 270²/1200 ≈ 60,8 + // юнита высоты — как в GMod (в Source гравитация 800 и высота 45,6). 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); @@ -2546,11 +3211,11 @@ mod tests { peak = peak.max(player.position.z); } let height = peak - ground_z; - println!("ЗАМЕР прыжок: оторвался={left_ground} высота={height:.1} юнитов (в Source ~45)"); + println!("ЗАМЕР прыжок: оторвался={left_ground} высота={height:.1} юнита (в GMod ~60,8)"); assert!(left_ground, "прыжок не оторвал игрока от земли"); assert!( - height > 30.0, - "прыжок слишком низкий: {height:.1} юнитов при ~45 в Source" + height > 50.0, + "прыжок слишком низкий: {height:.1} юнита при ~60,8 в GMod" ); // 5. Падение сквозз мир: за 30 секунд ходьбы игрок либо на земле, либо @@ -2573,6 +3238,145 @@ mod tests { "игрок провалился сквозь мир на {}", rest_z - player.position.z ); + + // 6. Управление в воздухе: жалоба «в воздухе управление слабее, чем в + // оригинальном GMod». + // + // В Source поворот в полёте работает так: берётся проекция текущей + // скорости на желаемое направление, и прибавляется + // `min(sv_air_max_wishspeed, wishspeed) - currentspeed`. Поэтому + // поворот возможен только когда игрок **не летит в ту же сторону**: + // при беге вперёд и желании уйти вбок проекция 177 >> капа 30 и + // прибавки нет вовсе. Это не дефект, это устройство оригинала. + // + // Меряем то, что в Source действительно работает: прыжок без + // входа, затем резкий доворот в сторону. Скорость вбок должна + // упереться ровно в `sv_air_max_wishspeed` = 30. + let mut player = Player::new(spawn); + for _ in 0..10 { + player.update(&collision, 0.0, 0.0, false, false, 0.0, dt); + } + player.update(&collision, 0.0, 0.0, true, false, 0.0, dt); + let mut air_gain = 0.0f32; + for _ in 0..30 { + player.update(&collision, 0.0, 1.0, false, false, 0.0, dt); + air_gain = air_gain.max(player.velocity.y.abs()); + } + println!( + "ЗАМЕР управление в воздухе: боковая скорость {air_gain:.1} юнитов/с при рывке, sv_air_max_wishspeed = {AIR_MAX_WISH}" + ); + assert!( + (air_gain - AIR_MAX_WISH).abs() < 1.0, + "рывок вбок должен упираться в sv_air_max_wishspeed = {AIR_MAX_WISH}, а не в {air_gain:.1}" + ); + // И вторая сторона механики: при полёте вперёд уклон вбок не работает. + let mut player = Player::new(spawn); + for _ in 0..10 { + player.update(&collision, 0.0, 0.0, false, false, 0.0, dt); + } + for _ in 0..18 { + player.update(&collision, 1.0, 0.0, true, false, 0.0, dt); + } + let y_before = player.velocity.y; + for _ in 0..12 { + player.update(&collision, 1.0, 1.0, false, false, 0.0, dt); + } + println!( + "ЗАМЕР поворот при полёте вперёд: vy {y_before:.1} -> {:.1} (ожидается без изменений)", + player.velocity.y + ); + assert!( + (player.velocity.y - y_before).abs() < 1.0, + "поворот при полёте вперёд должен блокироваться sv_air_max_wishspeed, а vy изменилась с {y_before:.1} на {:.1}", + player.velocity.y + ); + + // 7. Торможение: жалоба «останавливаюсь долго». Разгоняемся и + // отпускаем ввод, меряем время и путь до полной остановки. + let mut player = Player::new(spawn); + for _ in 0..10 { + player.update(&collision, 0.0, 0.0, false, false, 0.0, dt); + } + // Полсекунды разгона: от спавна до стены 288 юнитов, а полторы + // секунды разгона упираются в неё лбом — тогда меряется не торможение, + // а откаты от стены. + for _ in 0..30 { + player.update(&collision, 1.0, 0.0, false, false, 0.0, dt); + } + let stop_from = player.velocity.length(); + let stop_pos = player.position; + let mut stop_frames = 0u32; + while stop_frames < 600 && player.velocity.length() > 10.0 { + player.update(&collision, 0.0, 0.0, false, false, 0.0, dt); + stop_frames += 1; + } + let stop_seconds = stop_frames as f32 * dt; + let stop_distance = (player.position - stop_pos).length(); + println!( + "ЗАМЕР торможение: с {stop_from:.1} юнитов/с до 10 за {stop_seconds:.2} с, путь {stop_distance:.0} юнитов" + ); + assert!( + stop_seconds < 1.0, + "торможение слишком долгое: {stop_seconds:.2} с" + ); + assert!( + stop_distance < 400.0, + "тормозной путь слишком длинный: {stop_distance:.0} юнитов" + ); + + // 8. Ускорение падения: жалоба «падаю как мешок, без ускорения». + // Падаем в пустоту и смотрим вертикальную скорость по секундам. + let empty = Collision::default(); + let mut faller = Player::new(Vec3::ZERO); + let mut speeds = Vec::new(); + for step in 0..300 { + faller.update(&empty, 0.0, 0.0, false, false, 0.0, dt); + if step % 60 == 59 { + speeds.push(-faller.velocity.z); + } + } + println!( + "ЗАМЕР падение: по секундам {:?} юнитов/с", + speeds.iter().map(|v| v.round() as i64).collect::>() + ); + assert!( + speeds.windows(2).all(|w| w[1] >= w[0]), + "падение не ускоряется: {speeds:?}" + ); + assert!( + speeds[3] > speeds[0] * 2.0, + "падение не разгоняется после sv_falldistance: {speeds:?}" + ); + assert!( + speeds[4] <= MAX_VELOCITY + 1.0, + "падение пробило потолок sv_maxvelocity: {:?}", + speeds[4] + ); + + // 9. Уступы на карте: жалоба «стеньки в каждом переходе, которые можно + // только в прыжке пройти». Идём вперёд и считаем кадры, где игрок + // жмёт «вперёд», но почти не двигается: это упор в бордюр. + let mut player = Player::new(spawn); + for _ in 0..300 { + player.update(&collision, 0.0, 0.0, false, false, 0.0, dt); + } + let mut stuck = 0u32; + let mut previous = player.position; + for _ in 0..1800 { + player.update(&collision, 1.0, 0.0, false, false, 0.0, dt); + if previous.distance(player.position) < 0.05 { + stuck += 1; + } + previous = player.position; + } + println!( + "ЗАМЕР упоры за 30 с ходьбы: {stuck} кадров, финиш {:?}", + player.position + ); + assert!( + stuck < 120, + "игрок упирается в бордюры {stuck} кадров из 1800 — их пришлось бы перепрыгивать" + ); } /// Скорость ограничена `sv_maxvelocity` по каждой оси. @@ -2622,6 +3426,60 @@ mod tests { ); } + /// Коллизия рампы совпадает с её настоящим объёмом, а не с габаритом. + /// + /// Жалоба: «на рампах остались невидимые стены, они как бы превращают рампу + /// в параллелепипед». То есть кирпич с наклонной гранью обрабатывается как + /// ящик по габариту. + /// + /// Метод: берём точку-кандидат маленьким габаритом и спрашиваем у коллизии, + /// внутри ли она. Ответ сверяем с честным определением полупространств кирпича: + /// точка внутри тогда и только тогда, когда满足 всем граням. + #[test] + fn slanted_brush_is_not_treated_as_a_box() { + let tan = 1.0f32; // 45°. + let len = (1.0 + tan * tan).sqrt(); + let normal = Vec3::new(-tan / len, 0.0, 1.0 / len); + let planes = 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), + ]; + let brush = Brush::new(planes.clone()); + let point = |p: Vec3| -> bool { planes.iter().all(|(n, d)| n.dot(p) <= d + BRUSH_EPSILON) }; + // Опорная точка на самой поверхности рампы `z = x`. + let on_surface = |x: f32, lift: f32| Vec3::new(x, 0.0, x + lift); + + // Точки ПОД наклонной гранью, но внутри габарита кирпича: именно они + // превращают рампу в ящик. + let mut false_inside = 0; + let mut checked = 0; + for x in [0.0f32, 64.0, 256.0, 512.0] { + for lift in [-20.0f32, -8.0, -2.0, 2.0, 20.0, 60.0] { + let p = on_surface(x, lift); + if !point(p) { + continue; // честно вне кирпича — пропускаем + } + checked += 1; + let aabb = Aabb::new(p - Vec3::splat(0.5), p + Vec3::splat(0.5)); + if Collision::penetration(&brush, &aabb).is_none() { + false_inside += 1; + println!("ЗАМЕР ложное «вне рампы» в точке {p:?}"); + } + } + } + println!( + "ЗАМЕР рампа: проверено {checked} точек, коллизия ошибочно объявила их вне кирпича: {false_inside}" + ); + assert_eq!( + false_inside, 0, + "коллизия рампы не совпадает с её полупространствами" + ); + } + /// Игрок не проходит сквозь крутую рампу. /// /// Регрессия на «сквозь рампу прошёл». Рампа — кирпич с наклонной гранью, diff --git a/docs/PARITY.md b/docs/PARITY.md index 53bbeb1..f22ffd9 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -1179,3 +1179,121 @@ for (i=0; i < 3; i++) { с обнулением оси на 0). Тесты: `speed_is_capped_at_max_velocity`, `non_finite_velocity_is_zeroed`. + +## Часть B8. Свары GMod: константы и «невидимые стены» (сделано) + +Четыре жалобы, все — из игры, все закрыты измерением. + +### B8.1. «В воздухе управление слабее, чем в оригинальном GMod» + +Таблица cvar'ов GMod (gmodwiki.com/ConVars_In_Garrysmod) и код +`AirAccelerate` из CS:GO-версии `gamemovement.cpp` дают разные вещи: + +```cpp +wishspd = wishspeed; +if (wishspd > 30) wishspd = 30; // цель ограничена +addspeed = wishspd - currentspeed; +accelspeed = accel * wishspeed * frametime; // а прибавка — по полной +``` + +Кап `sv_air_max_wishspeed` = 30 ограничивает **цель**, но не величину прибавки. +Мы ограничивали и то и другое, из-за чего прибавка была в 250/30 ≈ 8,3 раза +слабее — это и есть ощущение «вялого управления в воздухе». + +Замер после правки на gm_construct: рывок вбок в полёте даёт ровно **30,0** +юнитов/с (кап достигается за один кадр, как в Source). Поворот при полёте +вперёд по-прежнему заблокирован: проекция скорости на желаемое направление 177 +>> капа 30, прибавки нет — **это верное поведение оригинала**, а не дефект. + +Заодно: `currentspeed` считается по **полной трёхмерной** скорости, а не по +горизонтальной. В `AirMove` вертикаль не обнуляется, поэтому в падении управление +слабее, чем в горизонтальном полёте, — ровно как в Source. + +### B8.2. «Трение слабое, останавливаюсь долго» + +Константы брались из SDK 2013, а у GMod они переопределены: + +| cvar | SDK 2013 | **GMod** | было у нас | +|---|---|---|---| +| `sv_friction` | 4 | **8** | 4 | +| `sv_stopspeed` | 100 | **10** | 100 | +| `sv_gravity` | 800 | **600** | 800 | +| `sv_airaccelerate` | 12 | **10** | 12 | +| `sv_accelerate` | 10 | 10 | 10 | +| `sv_maxvelocity` | 350 | **3500** | 350 | +| `sv_stepsize` | 18 | 18 | 18 | +| `sv_walkable_normal` | 0,7 | 0,7 | 0,7 | + +Замер после правки: с 250 юнитов/с до 10 — за **0,40 с**, путь **26 юнитов**. + +`sv_maxvelocity` = 3500, а не 350, — отдельная история: это потолок *по осям*, и +при значении 350 вертикальная скорость падения упиралась в него сразу. Отсюда +жалоба «падаю как мешок, без ускорения». + +### B8.3. Прыжок и падение + +`sv_gravity` = 600 и прыжок 270 дают 270²/1200 = **60,8** юнита высоты — как в +GMod (замерено 60,8). В Source с гравитацией 800 было 45,6. + +Падение в пустоте по секундам: **600, 1200, 1800, 2400, 3000** юнитов/с — +равномерный разгон по `g·t` до потолка `sv_maxvelocity`. Прежде было +585, 589, 590 — то есть упиралось в потолок 350 и стояло на месте. + +### B8.4. О `sv_falldistance` / `fall_speed`: гипотеза, отброшенная измерением + +Поначалу показалось, что «ускорение падения» — это пара cvar'ов HL2 +`sv_falldistance` + `fall_speed`. Проверка: в таблице cvar'ов GMod их **нет**, а +формула `velocity[2] -= velocity[2] * fall_speed * frametime` при отрицательной +`velocity[2]` даёт **замедление** падения, а не разгон. Ставить непроверенные +константы значило бы гадать, поэтому пара не вводится: ускорение падения в GMod +даёт постоянная гравитация плюс высокий `sv_maxvelocity`. + +### B8.5. «Высота шага терялась единица» + +Подшаг поднимает тело ровно на `sv_stepsize` = 18, и тогда низ габарита +становится **точно на грань** ступени. Грань в этот момент считается касанием, +отсечение обнуляет шаг, и подшаг блокирует сам себя. + +Замер до правки: игрок перебирал уступы до **17** и застревал на **18**. В +оригинале подъём идёт на `flStepSize + DIST_EPSILON` — не округление, а +необходимость. + +После введения `STEP_EPSILON` = 0,1: перебираются все уступы до **18** включительно, +19 — нет. Ровно `sv_stepsize`. + +### B8.6. «Невидимые стены, рамка превращается в параллелепипед» + +Грань кирпича ограничивает **плоскость**, а не сам кирпич. Стоит телу оказаться +на уровне этой плоскости где угодно — грань останавливает шаг, даже если +кирпича там нет. + +Замер на gm_construct: игрок, идущий по полу на `z = -144`, останавливался за +**3,4 юнита** до настоящей преграды — блокировала верхняя грань соседнего +кирпича, до которого было далеко. Такие грани встречаются на каждом стыке +участков, а на наклонных поверхностях образуют блоки, сравнимые с габаритом +целого кирпича, — «рампа превращается в параллелепипед». + +Правка: при `t` около нуля (шаг обнуляется полностью) проверяется, что точка +касания лежит на самом кирпиче, а не на продолжении его грани. + +Проверка узкая и только для `t < 0,001` намеренно: проверять точку касания для +всех `t` нельзя — при спуске с подшага тело опускается вертикально и в момент +касания ещё находится в стороне от грани по горизонтали. На широкой проверке +игрок переставал перебирать уступы уже высотой 4 юнита (замерено). + +Замер после правки: преград «из воздуха» на 1800 шагов ходьбы по gm_construct — +**0** (было 2), и упоров в бордюры за 30 с ходьбы — **0** кадров. + +### B8.7. Проверенные гипотезы, оказавшиеся невиновными + +Каждая проверялась замером, а не рассуждением, и все оказались в порядке: + +- **Кисти кирпичей как ящик.** Синтетическая наклонная грань сходится с + полутночными пространствами точно (`slanted_brush_is_not_treated_as_a_box`). +- **Объём кирпичей на карте.** Аудит всех 2126 кирпичей gm_construct по сетке + 4×4×4 точек: **0 расхождений** (129684 проверок). +- **Displacement выпадает из сетки.** `add_displacement_tris` молча выбрасывает + треугольник, размазанный больше чем по 2048 юнитам. На карте: 11776 + треугольников, **выпало 0**. +- **Скорость вдоль склона.** Рампа — не ящик: 239–263 юнита/с против 250 на + ровном, при подъёме застреваний 0.