Четыре жалобы из игры, все закрыты измерением. Константы движения (gmodwiki.com/ConVars_In_Garrysmod, не source-sdk-2013): sv_friction 4 -> 8 торможение 250 -> 10 за 0,40 с, путь 26 юнитов sv_stopspeed 100 -> 10 sv_gravity 800 -> 600 прыжок 45,6 -> 60,8 юнита, как в GMod sv_airaccelerate 12 -> 10 sv_maxvelocity 350 -> 3500 падение 585/589/590 -> 600/1200/1800/2400/3000 «В воздухе управление слабее»: в AirAccelerate кап sv_air_max_wishspeed ограничивает ЦЕЛЬ, а прибавка считается по некапленной wishspeed. Мы ограничивали и то и другое — прибавка была в 8,3 раза слабее. Замер после правки: рывок вбок даёт ровно 30,0 юнитов/с, как в Source. Поворот при полёте вперёд по-прежнему заблокирован (проекция 177 >> капа 30) — это верное поведение оригинала. «Высота шага терялась единица»: подъём ровно на sv_stepsime ставит низ габарита точно на грань ступени, грань считается касанием, и подшаг блокирует сам себя. Замерено: брались уступы до 17 и застревали на 18. В оригинале подъём на flStepSize + DIST_EPSILON. После правки — все до 18 включительно, 19 нет. «Невидимые стены, рампа как параллелипипед»: грань кирпича ограничивает плоскость, а не кирпич, и останавливает шаг где угодно на её уровне. Замерено: игрок останавливался за 3,4 юнита до настоящей преграды, блокировала верхняя грань соседнего кирпича. Правка: при t около нуля проверяется, что точка касания лежит на самом кирпиче. Замер после правки: преград «из воздуха» 0 (было 2), упоров за 30 с ходьбы 0. Проверено и отброшено измерением: кисти не работают как ящик (аудит 2126 кирпичей, 0 расхождений из 129684 точек); displacement не выпадает из сетки (11776 треугольников, выпало 0); скорость вдоль склона 239-263 против 250. Пара sv_falldistance/fall_speed НЕ введена осознанно: в таблице cvar'ов GMod её нет, а формула HL2 при отрицательной velocity[2] даёт замедление падения, а не разгон. Ставить непроверенные константы значило бы гадать. 228 тестов зелёные, clippy -D warnings чист.
1299 lines
86 KiB
Markdown
1299 lines
86 KiB
Markdown
# План Source RS: одиночная игра, совместимая с GMod
|
||
|
||
**Статус:** план согласован, начало исполнения (2026-10-12).
|
||
**Каталог:** `~/garrysmod-rs`.
|
||
**Связано:** `PLAN.md` (общий план), `PROBLEMS.md` (боли), `RECON.md` (реверс).
|
||
|
||
Каждый этап — рабочий коммит и проверяемый результат, а не заготовка.
|
||
|
||
---
|
||
|
||
## Главный принцип: совместимость, а не тождество внутренностей
|
||
|
||
Решение пользователя: «1 в 1 не надо прям, но надо совместимость».
|
||
|
||
Это меняет критерий готовности. Нас интересует не внутреннее устройство GMod,
|
||
а то, что снаружи него видно и от чего зависит чужой код:
|
||
|
||
| Что должно работать | Почему это «совместимость» |
|
||
|---|---|
|
||
| **Контент** карт и аддонов: BSP, VTF, VMT, MDL, VTX, а также `lua/` с аддона | аддон физически не запустится без этого |
|
||
| **Lua-API**: `Entity()`, `ents.*`, `Vector`/`Angle`/`Color`, хуки, таймеры, `file`, `surface`, `sound`, `render`, `cam`, `Curves` | код аддона должен выполняться без правок |
|
||
| **Поведение игрока и физика** | геймплей аддона завязан на это |
|
||
| **Формат карт**: сущности читаем так же, как в Source | иначе нельзя открыть чужую карту |
|
||
| **Сетевой протокол** (уже сделано) | мультиплеер |
|
||
|
||
Что **не** требуется: внутренние структуры движка, точное совпадение
|
||
побайтовых раскладок в нестандартных случаях, поддержка всего, что есть в
|
||
`base_ents` GMod.
|
||
|
||
Практическое следствие: там, где можно, сверяемся с исходниками Source.NET и
|
||
реальными файлами, а не угадываем; а публичные Lua-имена и сигнатуры берём из
|
||
настоящих `lua/includes/` GMod, а не придумываем.
|
||
|
||
---
|
||
|
||
## Порядок (подтверждён)
|
||
|
||
**A → B → C → D → E**, причём **свет (C) раньше game.dll (E2)** — так решил
|
||
пользователь.
|
||
|
||
---
|
||
|
||
## Часть A. Найденные дефекты (то, что сломано прямо сейчас)
|
||
|
||
Часть пунктов пользователь заметил визуально, часть подтверждена замером по
|
||
реальному `gm_construct.bsp`.
|
||
|
||
### A1. Коллизия: скорость не гасится о поверхность
|
||
**Симптом** (пользователь): «подхожу в стену, отпускаю клавиши — входит внутрь
|
||
чуть-чуть, потом выталкивает».
|
||
|
||
**Причина** (`crates/engine/src/player.rs:432`): `move_aabb()` возвращает только
|
||
`(Aabb, on_ground)` и **не трогает `self.velocity`**. Каждый кадр игрок
|
||
сдвигается на `velocity * dt` внутрь стены, получает выталкивание и оказывается
|
||
на грани — визуально «ныряет» и отскакивает. Горизонтальная скорость в стену
|
||
никогда не обнуляется.
|
||
|
||
**Как в Source**: `CMoveData` отдаёт `clipNormal`, скорость проектируется на
|
||
плоскость контакта: `v -= n * min(0, dot(v, n))`.
|
||
|
||
**Исправление**: `move_aabb` возвращает нормали контакта; `Player::update` гасит
|
||
компоненту скорости внутрь каждой нормали и обнуляет `velocity.z` при
|
||
`on_ground`. Плюс явное торможение при отпускании клавиш.
|
||
|
||
**Приёмка**: тест «в стену на полной скорости → после отпускания стоит
|
||
неподвижно, `|velocity.xz| < 1`, положение не дрейфует»; тест на отсутствие
|
||
просадки в стену при `dt = 1/60` и скорости 250.
|
||
|
||
### A2. Коллизия: твёрдость браша определяется неверно
|
||
**Симптомы**: «невозможно пройти в здания, хотя проход есть», «вода твёрдая».
|
||
|
||
**Причина** (`player.rs:43`): `const TRIGGER: i32 = 0x4000_0000;` и проверка
|
||
`if contents == TRIGGER { continue }`. То есть твёрдым считается **всё, кроме
|
||
ровно `0x40000000`** — включая браши с `contents == 0` (по спецификации Source
|
||
`CONTENTS_SOLID = 0x1`, всё остальное не твёрдо) и браши воды/тумана.
|
||
|
||
**Замер по `gm_construct.bsp` (2203 браша, лумп 18):**
|
||
|
||
| contents | смысл | брашей | сейчас твёрдые | по спецификации |
|
||
|---|---|---|---|---|
|
||
| `0x08000001` | SOLID | 1130 | да | да |
|
||
| `0x00000001` | SOLID | 996 | да | да |
|
||
| `0x00000000` | EMPTY (не твёрдый) | **70** | **да** ✗ | нет |
|
||
| `0x08000080` | не SOLID | 3 | **да** ✗ | нет |
|
||
| `0x18000008` | не SOLID | 2 | **да** ✗ | нет |
|
||
| `0x10000020` | **WATER** | 1 | **да** ✗ | нет |
|
||
| `0x30000008` | не SOLID | 1 | **да** ✗ | нет |
|
||
|
||
Итого **77 из 2203 брашей (3.5%) считаются твёрдыми ошибочно** — невидимые стены
|
||
в проходах и твёрдая вода озера.
|
||
|
||
**Исправление**: твёрдым считать браш, у которого установлен бит
|
||
`CONTENTS_SOLID (0x1)`. Отдельно разобрать воду (`0x20`), туман (`0x40`),
|
||
окна (`0x2`) и решётки (`0x8`): они не останавливают игрока, но влияют на
|
||
жидкость и свет.
|
||
|
||
**Приёмка**: счётчик брашей по `contents` в логе; на gm_construct ожидается
|
||
2126 твёрдых и 77 не твёрдых; тесты «проход в здание открыт», «в воду можно
|
||
войти».
|
||
|
||
### A3. Коллизия: нет broadphase для брашей
|
||
2203 браша проверяются целиком на каждом подшаге, в каждой из 4 итераций
|
||
разрешения. Для displacement'ов есть сетка, для брашей — нет.
|
||
|
||
**Исправление**: 2D-сетка по XY с AABB каждого браша; запрос кандидатов по
|
||
расширенному AABB хода.
|
||
|
||
**Приёмка**: замер времени кадра коллизии до/после; цель — стабильные 60 fps
|
||
при обычном движении.
|
||
|
||
### A4. Рендер: отсечения граней нет вообще
|
||
**Симптом**: «видно обратную сторону брашей».
|
||
|
||
**Причина** (`crates/render/src/world_renderer.rs`): `cull_mode: None` во всех
|
||
**шести** пайплайнах (строки 247, 292, 385, 483, 613).
|
||
|
||
**Уточнение пользователя**: в оригинале сквозь стены тоже видно — но там это
|
||
намеренно (вид сквозь стену на HUD), а не отсутствие backface culling.
|
||
|
||
**Исправление**: `CullMode::Back` для мира и моделей; для двусторонней
|
||
геометрии — отдельный проход `CullMode::Front` с инверсией нормали (как в
|
||
Source: сначала фронт с инверсией, затем бэк).
|
||
|
||
**Приёмка**: в галерее gm_construct не видно изнанки стен.
|
||
|
||
### A5. Рендер: деталь-текстуры не поддержаны → нет песка у озера
|
||
**Симптом**: «у озера трава должна переходить в песок, песка нет».
|
||
|
||
**Причина**: в `crates/formats/src/bsp.rs` перечислены 64 лумпа, и **деталь-текстур
|
||
среди них нет**. В Source 1 они лежат в `GAME_LUMP` как под-лумп
|
||
`detailtextures.dll` (`LUMP_DETAILTEXTURES = 12`) и накладываются поверх базовой
|
||
текстуры. Шейдер `world.wgsl` (38 строк) читает только `base_texture` и
|
||
умножает на вершинный `light`.
|
||
|
||
**Исправление**:
|
||
1. Разбор `GAME_LUMP` (запись лумпа **16 байт**: `fileofs, disksize, offset,
|
||
fourCC`), под-лумп 12 → список `(текстура, scaleX, scaleY)`.
|
||
2. Проекция деталь-текстуры на мировые координаты по UV грани.
|
||
3. Шейдер: дополнительные сэмплы со смешением, `$detail` из VMT.
|
||
|
||
**Приёмка**: у озера gm_construct виден переход травы в песок.
|
||
|
||
### A6. Белая нетекстурированная поверхность (было: «белые панели на 0 0 0»)
|
||
**Симптом по скриншоту**: большая белая polygonal-поверхность, лежащая на
|
||
земле рядом с озером (камера на `pos -1070 1565 533`). Контур с прямыми
|
||
отрезками и острыми углами, поверхность явно повторяет рельеф местности.
|
||
Окружающие дома, трава, дороги и вода (слева, тёмно-синяя) выглядят нормально.
|
||
|
||
**Это не пропы.** Проп — это объёмная модель с чёткими гранями; здесь же
|
||
плоская поверхность, граница которой идёт по рельефу. Значит это браш- или
|
||
displacement-геометрия, у которой **не разрешилась текстура** — материал
|
||
рисуется `fallback`-белым (`Material::fallback`, `world.rs:739`).
|
||
|
||
Ранее я проверил пропы и ничего подозрительного не нашёл: пропов без `origin`
|
||
нет, с `origin = "0 0 0"` нет, наш код порождает всего 9 моделей
|
||
(5 × `prop_dynamic lights_flescent01a`, 4 × `prop_physics` мебели) — все с
|
||
корректными координатами. Это гипотеза отвергнута, версия снимка была неверной.
|
||
|
||
**Кандидаты причины, по убыванию вероятности**:
|
||
1. **Не найдена VTF-текстура** для части поверхностей — `fallback` рисует белым.
|
||
Особенно вероятно для displacement-поверхностей у воды: у них отдельные
|
||
`texinfo`, которые не всегда есть в списке.
|
||
2. **Не разрешён VMT** — тогда материал падает в тот же белый fallback.
|
||
3. Отдельная поверхность, которой полагается деталь-текстура, но базовая
|
||
не загрузилась (связано с A5).
|
||
|
||
**Действие** (одна итерация, даёт точный ответ): дамп всех материалов мира —
|
||
`{ имя VMT, имя VTF, загрузилась ли, причина отказа }` плюс AABB каждого
|
||
батча. По скриншоту видно, что проблема локальная (один район карты), значит
|
||
список «отвалившихся» текстур будет небольшим и читаемым.
|
||
|
||
**Побочный вывод**: белый fallback — плохая диагностика и плохое поведение.
|
||
Нужна проверка в `doctor` (см. B4) и осмысленная заглушка: тёмно-шахматная
|
||
вместо белой, чтобы выделять проблему, а не маскировать её.
|
||
|
||
### A8. Знаки и порядок осей при повороте моделей
|
||
`rotate_model_vector` строит базис `forward/left/up`, тогда как Source
|
||
использует `forward/right/up`. Модельная система координат MDL — правая
|
||
(X вперёд, Y влево, Z вверх), поэтому знак оси Y критичен: ошибка
|
||
разворачивает модель зеркально и ломает нормали.
|
||
|
||
Это не «на глаз» — сверяем с эталонным `AngleVectors` и закрываем unit-тестом
|
||
на известных углах (`0 0 0`, `0 90 0`, `0 0 90`, `90 0 0`), фиксируя
|
||
ожидаемые векторы из формулы Source.
|
||
|
||
### A7. Вода выглядит неправильно
|
||
**Симптом**: «просто какое-то отражение неба, никакой текстуры, твёрдая».
|
||
|
||
**Причины**:
|
||
- отражение — **зашитая константа** `vec3<f32>(0.64, 0.76, 0.90)` в
|
||
`water.wgsl:49`, а не окружение;
|
||
- одна поверхность с `discard` по знаку `facing`, тогда как в Source их две
|
||
(`$abovewater` 1 и 0) — верх и изнанка;
|
||
- нет преломления, поглощения по глубине, блика солнца, нормалей из текстуры,
|
||
`$fogcolor`-затухания;
|
||
- твёрдость — из A2.
|
||
|
||
**Исправление**: две поверхности; отражение из окружения; преломление через
|
||
цвет буфера; поглощение по расстоянию; specular от солнца; плюс A2.
|
||
|
||
---
|
||
|
||
## Часть B. Фундамент: система энтити
|
||
|
||
Без неё не появляется ни свет, ни Lua над энтити, ни физика. Свет — это тоже
|
||
энтити (`light_environment`, `light`, `light_spot`).
|
||
|
||
**B1. Ядро** (`crates/engine/src/entity.rs`)
|
||
- `EntityIndex(u16)` — слот + `generation` для безопасных «умерших» указателей;
|
||
- `Entity` — classname, keyvalues, transform (`origin`/`angles`/local-origin),
|
||
флаги (`solid`, `movetype`, `model`, `render`/`rendergroup`);
|
||
- `EntityWorld` — пул, `spawn(kv)`, `remove`, итерация по classname,
|
||
отложенное удаление (как в Source, чтобы не инвалидировать ссылки в кадре);
|
||
- лимиты из файлов: максимум энтити, максимум ключей, максимум длины значения
|
||
(правило #1 AGENTS.md — никаких `unwrap` на данных из файлов).
|
||
|
||
**B2. Спавн из карты**: `build_world` перестаёт вручную порождать модели — он
|
||
скармливает лумп `ENTITIES` в `EntityWorld`, а рендер и коллизия читают из неё.
|
||
Это убирает белые панели как класс проблем и даёт всем остальным этапам единый
|
||
источник истины.
|
||
|
||
**B3. Коллизия по энтити**: браши `func_*`/`trigger_*`/`info_null` с корректной
|
||
твёрдостью; AABB/OBB для моделей; `trigger`-зоны отдельным слоем.
|
||
|
||
**B4. Отладка**: `ent_list` / `ent_info <classname>` — иначе каждый следующий
|
||
дефект снова придётся ловить на глаз.
|
||
|
||
**Приёмка**: `ent_list` показывает все энтити gm_construct с корректными
|
||
координатами; коллизия `func_*` работает.
|
||
|
||
---
|
||
|
||
## Часть C. Освещение (включая солнце)
|
||
|
||
Сейчас в шейдере один попиксельно интерполированный `light: vec3` из вершин —
|
||
меньше, чем в Source даже на простых картах.
|
||
|
||
**C1. Свет из карты**: `light`, `light_spot`, `light_dynamic` (в gm_construct:
|
||
108 `light` + 28 `light_spot`), `light_environment` — это и есть солнце
|
||
(направление, цвет, интенсивность, флаг «небо»).
|
||
|
||
**C2. Модель освещения в шейдере**: динамический свет (солнце + до N источников
|
||
в радиусе) поверх статического; источники ограничить по экрану и расстоянию.
|
||
|
||
**C3. Sun-тени**: каскадные shadow maps (в Source 3–4 каскада). Начать с одного
|
||
каскада 2048².
|
||
|
||
**C4. Lightmap**: лумп `LIGHTING`/`LIGHTING_HDR` (12 МБ в gm_construct) —
|
||
попиксельно вместо вершинного; даёт качественный свет в помещениях.
|
||
|
||
**C5. Материал**: `Phong`-подобная модель — diffuse + specular + fresnel;
|
||
`$phong`/`$specular`/`$color2` из VMT; самозатенение от карты нормалей.
|
||
|
||
**C6. Небо и `env_*`**: `env_sky`, `env_fog`, `env_sun`, `env_tonemap`,
|
||
`env_cubemap`, `env_skybox`.
|
||
|
||
**Приёмка**: на gm_construct видны солнечные тени от построек, помещения
|
||
освещены не плоско, вода отражает окружение, есть спекуляр на металле.
|
||
|
||
---
|
||
|
||
## Часть D. Lua поверх энтити
|
||
|
||
Текущий `lua` (1278 строк) — `print`, `hook.*`, `timer.Simple`,
|
||
`concommand.*`, заглушки. Ни типов, ни энтити.
|
||
|
||
**D1. Типы**: `Vector`, `Angle`, `Color` как userdata с метаметодами GMod
|
||
(арифметика, `Angle:Forward()`, `Vector:Distance`, `:Angle`).
|
||
|
||
**D2. `Entity(index)`** и методы: `GetPos/SetPos`, `GetAngles/SetAngles`,
|
||
`GetClass/GetModel/SetModel`, `Remove`, `IsValid`, `PhysicsInit*`.
|
||
|
||
**D3. `ents.*`**: `Create`, `FindByClass`, `FindInSphere`, `GetAll`, `GetCount`.
|
||
|
||
**D4. Сетевые поля**: `SetNW*`/`GetNW*` (в одиночке локальные, но с тем же API,
|
||
чтобы потом не переписывать аддоны).
|
||
|
||
**D5. Библиотеки GMod**: `file.*` (с песочницей по AGENTS.md), `surface.*`,
|
||
`sound.*`, `render.*`, `cam.*`, `Curves`, `system.*`, `scrconcmd`,
|
||
`LocalPlayer`. `http` — только после песочницы.
|
||
|
||
**Приёмка**: скрипт из гмода выполняется; список функций совместимости
|
||
(набранный из настоящих `lua/includes/modules/`) проходит.
|
||
|
||
---
|
||
|
||
## Часть E. Одиночная игра как в GMod
|
||
|
||
**E1. Player**: ходьба по ступеням (step-up ~18 units), лестницы, приседание с
|
||
плавным переходом, плавание (`Start Swim`), ноклип, падение с уроном.
|
||
|
||
**E2. game.dll на Lua**: загрузка `gamemodes/sandbox/gamemode/init.lua` —
|
||
списки `ents`, `weapons`, `NPCs`, хуки `Initialize`, `Think`, `PlayerSpawn`.
|
||
Это то, что делает GMod «GMod’ом», а не просто картой Source.
|
||
|
||
**E3. Оружие и предметы**: `weapon_*`, `item_*` через Lua.
|
||
|
||
**E4. Физика пропов**: Jolt — `prop_physics` уже приходят с карты, но не падают
|
||
и не сталкиваются.
|
||
|
||
**E5. NPC**: начиная с базовых `npc_*`.
|
||
|
||
---
|
||
|
||
## Порядок и разумная дистанция до GMod
|
||
|
||
GMod — это ~1 млн строк C++ плюс 15 лет аддон-экосистемы. Полное «1 в 1»
|
||
недостижимо как финальное состояние, но достижимо как наращиваемое покрытие:
|
||
A → B → C → D → E даёт к каждому моменту работающий одиночный клиент.
|
||
|
||
| Этап | Что даёт пользователю |
|
||
|---|---|
|
||
| A | «стены не пускают, вода не твёрдая, белая поверхность устранена, песок появился» |
|
||
| B | карта описывается объектами — база для всего |
|
||
| C | солнце, тени, нормальный свет |
|
||
| D | Lua-аддоны запускаются на нашей карте |
|
||
| E | одиночная игра, совместимая с аддонами |
|
||
|
||
Честное ограничение: «все энтити» — сотни классов. Но `base_ents` GMod — это
|
||
**Lua-скрипты**, а не C++. Когда появится исполнение `lua/entities/` и
|
||
`lua/weapons/`, значительная часть этих классов заработает сама, из исходников
|
||
GMod. Поэтому наш код на этих этапах делает только то, чего в Lua нельзя:
|
||
низкий уровень графики, физика, загрузка ресурсов.
|
||
|
||
Свой набор «тяжёлых» классов держим минимальным: `prop_physics`, `func_door`,
|
||
`func_button`, `trigger_*`, `light_*`, `env_*`, `info_*`. Остальное — из Lua.
|
||
|
||
---
|
||
|
||
## Решения, принятые пользователем
|
||
|
||
1. **Скриншот получен** → A6 переформулирован: это не панели пропов, а
|
||
нетекстурированная браш/displacement-поверхность. Версия про «панели в 0 0 0»
|
||
отвергнута.
|
||
2. **`AngleVectors`** → вопрос снят, работа делается как A8: сверка с
|
||
эталонной формулой Source и фиксация unit-тестом, без опоры на память.
|
||
3. **Приоритет** → свет (C) раньше game.dll (E2).
|
||
|
||
## Что осталось уточнить
|
||
|
||
Ничего блокирующего. Дополнительно уточню по ходу:
|
||
- белый fallback заменить на тёмно-шахматный (A6) — согласую при реализации,
|
||
это меняет вид карты при недогруженных текстурах.
|
||
---
|
||
|
||
## Часть C. Прогресс
|
||
|
||
### C0. Источники света из карты (сделано)
|
||
|
||
`engine::world::collect_lights()` разбирает `light_environment` (солнце),
|
||
`light`, `light_dynamic`, `light_fluoro_dynamic`, `light_spot`. На gm_construct
|
||
находится 65 источников, из них 1 солнце и 22 прожектора.
|
||
|
||
Шейдер `world.wgsl` получил uniform-блок источников: солнце (до 4), точечные
|
||
и прожекторные (до 64, отбираются ближайшие к камере), плюс ambient.
|
||
|
||
**Три ловушки, на которые напоролся (все проверены на gm_construct):**
|
||
|
||
1. **Четвёртая компонента `_light` означает разные вещи.** У `light` и
|
||
`light_spot` это РАДИУС освещения (`_light 255 255 255 300`), а у
|
||
`light_environment` — множитель яркости солнца (`_light 242 234 218 560`).
|
||
Если считать это яркостью везде, лампы дают вклад ×300 и карта выбивается
|
||
в белый.
|
||
|
||
2. **Яркость нельзя домножать в цвет.** Если умножить цвет на `scale` при разборе
|
||
и учесть `scale` ещё раз в шейдере, получается квадрат яркости. Цвет
|
||
нормируется к единичному среднему, яркость хранится отдельным полем `scale`.
|
||
|
||
3. **Радиус 0 означал «светить во everywhere».** Функция затухания при
|
||
радиусе 0 возвращала 1.0 на любом расстоянии, и 22 прожектора складывались в
|
||
сумму. Теперь у ламп без `_radius` подставляется 300.
|
||
|
||
4. **Лайтмапа уже содержит запечённое солнце.** Это ключевой момент: `in.light`
|
||
— это и есть запечённый свет компилятора. Динамическое солнце сверху нужно
|
||
только для направленной подсветки и будущих теней, поэтому его вклад
|
||
намеренно мал (`SUN_BRIGHTNESS_SCALE = 900` даёт ~0.62 от карты). Наивная
|
||
попытка добавить солнце «как есть» давала пересвет бетона в чистый белый.
|
||
|
||
Итоговые нормировки подобраны рендерами на gm_construct и лежат константами
|
||
`SUN_BRIGHTNESS_SCALE`, `POINT_BRIGHTNESS_SCALE`, `AMBIENT` рядом с кодом.
|
||
|
||
### Осталось по части C
|
||
|
||
- Попиксельная лайтмапа (`LUMP_LIGHTING`) вместо вершинного света.
|
||
- Тени от солнца (каскадные shadow maps).
|
||
- `Phong`-модель: specular + fresnel, `$phong`/`$specular` из VMT.
|
||
- Ambient из `_ambient` карты вместо константы.
|
||
- Полный набор `env_*`.
|
||
|
||
## Часть B2. Физика: Rapier3D (сделано)
|
||
|
||
### B2. Решение: мир не переезжает в Rapier
|
||
|
||
Rapier обслуживает только **динамические тела**. Геометрия карты остаётся
|
||
брашевой и разбирается нашим кодом (`player.rs`) — он проверен и быстрее на
|
||
тысячах брашей. Статика в Rapier добавляется явно и только из данных.
|
||
|
||
### B2.1. Четыре дефекта, найденных измерением
|
||
|
||
Все четыре нашлись не «правдоподобным декодированием», а замером на карте.
|
||
Порядок важен: каждый следующий дефект был бы невидим, пока не закрыт
|
||
предыдущий.
|
||
|
||
1. **Рельеф раздувался в 15 277 коллайдеров.** Сетка по `LAND_CELL = 64`
|
||
дублировала каждый перекрытый треугольник в каждую ячейку: 89 треугольников
|
||
давали 15 277 коллайдеров. Заменено одним объединённым trimesh с
|
||
дедупликацией вершин по кванту 1/16 юнита: **11 776 треугольников →
|
||
6 148 вершин, 1 коллайдер**. Точнее (никакого огрубления на швах ячеек) и
|
||
на порядки дешевле.
|
||
|
||
2. **Уровень gm_construct построен брашами, а не displacement'ами.** Рельеф
|
||
покрывает лишь часть карты; игрок ходит по 2 126 брашам. Без брашей все
|
||
пропы падали насквозь. `PhysicsWorld::add_brushes` восстанавливает вершины
|
||
браша перебором троек плоскостей (Крамер): точка, подходящая под **все**
|
||
плоскости — вершина выпуклой оболочки. У осевого браша 6 плоскостей = 20
|
||
троек. Замер: **2121 построен, 5 пропущено** (вырожденные).
|
||
|
||
3. **Broad-phase Rapier наполняется только на `step()`.** После вставки
|
||
геометрии луч её не видел: «под пропом пола нет» при 2 121 браше в мире.
|
||
Лечится явной регистрацией `broad_phase.set_aabb()` при вставке статики.
|
||
|
||
4. **`setup_physics` стирал построенное.** Начинался с
|
||
`self.physics = PhysicsWorld::new()` и обнулял браши, добавленные строкой
|
||
выше. Мир физики больше не пересоздаётся.
|
||
|
||
### B2.2. Тест поймал баг, который замер бы не увидел
|
||
|
||
`add_brushes` создавал коллайдеры, но **не увеличивал счётчик**: лог показывал
|
||
`static = 1`, хотя брашей было 2 121. Тест `box_brush_lands_where_the_box_is`
|
||
упал на `static_bodies() == 1` и указал прямо на строку.
|
||
|
||
### B2.3. Подтверждение на gm_construct
|
||
|
||
Две независимые системы коллизий сверены друг с другом: брашевая (`Collision`)
|
||
и Rapier. Обе находят пол под пропами на **z = −96** (Rapier −96.0,
|
||
брашевая −95.9). После 400 кадров все 4 `prop_physics` осели:
|
||
`speed = 0.0`, `sleeping = true`.
|
||
|
||
### B2.4. Ограничения
|
||
|
||
- Коллайдер пропа — **AABB модели**, а не `vcollide`. Source берёт физическую
|
||
модель из MDL; мы её не разбираем. AABB всегда не меньше модели и всегда
|
||
из данных, но для наклонённых пропов он «жирнее» настоящего.
|
||
- Только `prop_physics*`. У `prop_static` физики в GMod нет.
|
||
- **FPS просел**: 10–21 в headless-прогоне. Требуется замер отдельно с
|
||
физикой и без — 2 121 выпуклый коллайдер может быть причиной.
|
||
|
||
### B2.5. Что осталось по части B2
|
||
|
||
- `vcollide` из MDL вместо AABB.
|
||
- Брашевые `func_door` / `func_button` как ограничивающие тела.
|
||
- `env_explosion` через `apply_explosion` (метод готов, вызов из Lua — нет).
|
||
- Замер FPS с физикой и без, чтобы понять цену 2 121 коллайдера.
|
||
|
||
## Часть D1. Lua-типы GMod (сделано)
|
||
|
||
### D1. Что сделано
|
||
|
||
`crates/lua/src/gmod.rs` — `Vector`, `Angle`, `Color` как userdata с
|
||
арифметикой, полями на запись и `tostring` в формате GMod. Установлены в
|
||
глобалы вместе с `math.Clamp`.
|
||
|
||
Решения, важные для совместимости:
|
||
|
||
- **Пропущенных аргументов не бывает.** `Vector()` и `Vector(1, 2)` обязаны
|
||
работать: так вызывается бытовой код Sandbox. Отсутствующее значение
|
||
заменяется нулём, а не ошибкой.
|
||
- **Деление на ноль даёт 1, а не `inf`.** `inf` в позиции пропа отравляет
|
||
геометрию всего объекта и потом всплывает в отрисовке.
|
||
- **NaN из скрипта не доезжает до физики.** `Color(0/0, ...)` даёт белый,
|
||
`Vector(0/0, 1, 2)` — вектор без NaN.
|
||
- **Компоненты цвета обрезаются в 0..255**, а не отвергаются:
|
||
`Color(1.5 * 300, 0, 0)` — обычное дело в скриптах.
|
||
- **Source — левая система координат**: `Angle(0,0,0):Forward()` смотрит вдоль
|
||
`+X`, `Angle(0,90,0):Forward()` — вдоль `+Y`, знак Z у forward
|
||
отрицательный.
|
||
|
||
### D1.1. Дефект чтения чисел в mlua 0.11
|
||
|
||
`Value::as_number()` в mlua 0.11 различает **только** `Value::Number` и молча
|
||
возвращает `None` на `Value::Integer`. LuaJIT отдаёт целые именно как
|
||
`Integer`, поэтому наивное чтение теряло **все** целочисленные аргументы:
|
||
|
||
Vector(1, 2, 3) --> Vector(0, 0, 0)
|
||
|
||
Выглядело это как «арифметика не работает», хотя дело было в чтении чисел.
|
||
Собственный `value_number()` принимает `Number`, `Integer` и строку (числом).
|
||
Тест на это обязателен: без него `Vector()` с целыми просто тихо отдаёт нули.
|
||
|
||
### D1.2. Шум тригонометрии
|
||
|
||
`cos(90°)` в f32 — это 6.1e-17, а не ноль. Без обнуления
|
||
`Angle(0, 90, 0):Forward()` печатает `-0.00000004371139 1 -0`, и такие
|
||
значения копятся в преобразованиях координат. Порог — 1e-6.
|
||
|
||
### D1.3. Особенности API mlua 0.11, на которые ушло время
|
||
|
||
- Для userdata **нет** автоматического `FromLua`: чтение только через
|
||
`AnyUserData::borrow::<T>()`. `Lua::from_value` отсутствует, есть
|
||
`FromLua::from_lua(value, lua)`.
|
||
- Поля — `add_fields` (трейт `UserDataFields`), методы и метаметоды —
|
||
`add_methods` (трейт `UserDataMethods`). Один трейт на всё не работает.
|
||
- `add_meta_method` получает `(&Lua, &T, A)`: значение объекта идёт вторым
|
||
аргументом, лишние аргументы Lua (`__unm(a, a)`) отбрасываются через `()`.
|
||
- `add_function` получает объект как `AnyUserData` первым элементом кортежа.
|
||
|
||
### D1.4. Что осталось по части D1
|
||
|
||
- `Entity()` и объект сущности: `GetPos`/`SetPos`/`GetClass`/`IsValid`.
|
||
- `ents.*`: `Create`, `GetAll`, `FindInSphere`, `GetPlayer`.
|
||
- Связка с движком: `glua` не должен зависеть от `engine`, обмен — через
|
||
колбэки, которые регистрирует клиент.
|
||
- `Vector:Angle()`, `Vector:Distance` между сущностями.
|
||
|
||
## Часть D2. Мост Lua к движку (сделано)
|
||
|
||
### D2.1. Устройство
|
||
|
||
`glua` **не зависит** от `engine`. Движок отдаёт поведение через трейт
|
||
`glua::ents::Host`, реализация которого живёт в клиенте
|
||
(`crates/client/src/host.rs`). Иначе получился бы цикл зависимостей и
|
||
Lua-слой нельзя было бы тестировать в одиночку.
|
||
|
||
Мир переведён в `Rc<RefCell<World>>`: скрипт обращается к нему из хуков в
|
||
любой момент, пока кадр держит заимствование. Все обращения идут через
|
||
`try_borrow`, потому что `borrow` при пересечении даёт `panic` от
|
||
`RefCell`, а падать из-за безобидного скрипта клиент не должен.
|
||
|
||
### D2.2. Что доступно скрипту
|
||
|
||
`Entity(n)`, `ents.GetAll/GetByClass/FindInSphere/Create`,
|
||
`player`, а также `GetPos/SetPos/GetAngles/SetAngles/GetClass/GetModel/
|
||
GetVelocity/IsValid/Remove/GetName`.
|
||
|
||
`Entity(0)` — игрок, `Entity(9999)` — невалидная сущность, а не ошибка:
|
||
скрипты routinely проверяют результат через `IsValid()`.
|
||
|
||
### D2.3. Найденный рассинхрон (важный)
|
||
|
||
`move_prop` обновлял позу экземпляра пропа, но **не** запись в пуле
|
||
сущностей. Проверено измерением: `ents.GetByClass('prop_physics'):GetPos()`
|
||
давал `z = -64`, пока физика уже опустила проп на `z = -84`. «Данные» и
|
||
«картинка» разошлись, и заметить это можно было только глазами.
|
||
|
||
Теперь поза пишется в оба места всегда, даже если геометрию пересчитать
|
||
нечем (нет модели): проп без модели всё равно стоит правильно, он просто не
|
||
рисуется. Возвращаемое значение означает именно «геометрия пересчитана».
|
||
|
||
Проверка после правки: Lua читает `z = -84.15848`, физика даёт
|
||
`-84.15847778320313` — совпадение до последнего бита.
|
||
|
||
### D2.4. Границы
|
||
|
||
- `SetModel`, `SetColor`, `PhysicsInit` принимаются и ничего не делают:
|
||
принимать вызов нужно, чтобы скрипты не падали, но обещать нечего.
|
||
- `GetVelocity` делит скорость тела на шаг 1/60, а не читает «скорость за
|
||
последний кадр»: иначе скрипт получил бы юниты/кадр вместо юнитов/с.
|
||
- Созданная из Lua сущность не получает модель и тело физики.
|
||
- Игрок — `None`, пока клиент его не зарегистрировал.
|
||
|
||
### D2.5. Что осталось по части D2
|
||
|
||
- `Vector:Distance`, `Vector:Angle`, `GetModel`-обратная привязка.
|
||
- `Entity:GetTable`/`SetField` и сетевые функции.
|
||
- Регистрация сущности игрока, чтобы `Entity(0)` и `player` совпали.
|
||
- `ents.Create` должен уметь заводить тело физики для `prop_physics`.
|
||
- Подключение файлов `gamemodes/sandbox` как есть.
|
||
|
||
## Часть D3. Стандартная библиотека GMod (сделано)
|
||
|
||
### D3.1. Замер потребностей, а не догадка
|
||
|
||
Прогнаны 130 файлов `gamemodes/sandbox`, из них извлечены 182 различных вызова
|
||
API. Частоты по числу вхождений:
|
||
|
||
| вызов | вхождений | состояние |
|
||
|---|---|---|
|
||
| `hook.Run` | 63 | сделано |
|
||
| `math.Clamp` | 61 | сделано |
|
||
| `game.SinglePlayer` | 36 | сделано |
|
||
| `math.Rand` | 27 | сделано |
|
||
| `render.AddBeam` | 21 | не сделано |
|
||
| `math.min` / `math.max` | 17 / 5 | стандартная Lua |
|
||
| `net.*` | 15+ | не сделано (сеть) |
|
||
| `render.*` | 50+ | не сделано (рендер) |
|
||
| `file.*` | 8+ | сделано с песочницей |
|
||
|
||
`hook.Run` и `math.*` — самые частые и самые дешёвые: они чисто Lua-слой.
|
||
`render.*` — это отдельная большая работа (лучи, спрайты, декали), она не в
|
||
этом инкременте.
|
||
|
||
### D3.2. `hook.Run` не синоним `hook.Call`
|
||
|
||
`hook.Run` **останавливается** на первом хуке, вернувшем не-`nil`, и возвращает
|
||
его значение. На этом построены все проверки Sandbox (`return false` из хука
|
||
отменяет действие), поэтому подмена `Run` на `Call` молча ломала бы их.
|
||
|
||
### D3.3. Дефект: ключи хуков — строки, а не числа
|
||
|
||
Отбор только целочисленных ключей давал **пустой** список: `hook.Add` пишет под
|
||
идентификатором скрипта (`"GM:Think"`, `"a"`). Хуки не выполнялись вообще,
|
||
`hook.Run` возвращал `nil`, и это выглядело как «хуки не работают». Теперь
|
||
ключи сортируются как строка/число, порядок выполнения — по регистрации.
|
||
|
||
### D3.4. Дефект mlua: `Function::call` теряет аргументы
|
||
|
||
`Vec<Value>` реализует `IntoLua` как **таблицу**, поэтому `hook.call(args)`
|
||
передавал хуку один аргумент вместо трёх — молча, без ошибки. Проверено
|
||
измерением: `select("#", ...)` внутри хука давал `1` при трёх переданных.
|
||
|
||
То же с одиночным числом: `random.call::<f64>(10.0)` вызывал `math.random()`
|
||
без аргументов.
|
||
|
||
Лечится через `Variadic::from(vec![...])`. Отдельный тест
|
||
`hook_run_forwards_every_argument` фиксирует именно эту ошибку — она
|
||
молчаливая, и без теста вернётся.
|
||
|
||
По этой же причине `math.Rand` определён **на Lua**, а не на Rust: он зовёт
|
||
`math.random` того же состояния и передаёт аргументы нативно.
|
||
|
||
### D3.5. Песочница `file.*`
|
||
|
||
`file.Read` в GMod читает что угодно; мы запускаем чужие скрипты. Любой путь
|
||
разрешается относительно `garrysmod/` и нормализуется; выход за корень —
|
||
отказ, а не предупреждение. Абсолютные пути скрипту не даны. Размер чтения
|
||
ограничен 8 МБ (правило #1 AGENTS.md).
|
||
|
||
Проверка на живом клиенте: `file.Read("../../../../etc/passwd")` вернул `nil`.
|
||
|
||
### D3.6. Совпадения с GMod, зафиксированные тестами
|
||
|
||
- `math.Round(-2.5)` = **-3** (округление от нуля, как `ceil`).
|
||
- `math.Rand(x)` = `math.random(x)` → целое в **[1, x]**, а не [0, x).
|
||
- `math.Rand(a, b)` включает верхнюю границу.
|
||
- `math.Remap` при вырожденном диапазоне даёт `out_min`, а не `NaN`.
|
||
- `math.Approach` не перескакивает цель.
|
||
- `math.AngleDifference(10, 370)` = **0**: это одно направление.
|
||
- `string.Explode` с пустым разделителем возвращает `nil`, а не список.
|
||
|
||
### D3.7. Что осталось по части D3
|
||
|
||
- `render.*`: лучи, спрайты, декали, материалы, `DrawWireframeBox`.
|
||
- `net.*`: сообщения серверу.
|
||
- `Cam`, `LocalPlayer`, `vgui`/`derma` — для Q-меню.
|
||
- `util.TraceLine`, `util.Compress`, `surface.*`.
|
||
|
||
## Часть B2.6. Констрейны ( Rapier joints)
|
||
|
||
### Что сделано
|
||
|
||
`crates/engine/src/constraints.rs` — соединения между телами, соответствующие
|
||
классам GMod: `phys_ballsocket`, `phys_hinge`, `phys_slider`, `phys_weld`, `rope`.
|
||
Плюс `add_force` (база для `thruster`/`hover`) и `set_angular_velocity_target`
|
||
(мотор шарнира).
|
||
|
||
### B2.6.1. Главная тонкость: якоря мировые, а Rapier ждёт локальные
|
||
|
||
Скрипт задаёт точку крепления в **мировых** координатах, Rapier — **в
|
||
локальных** каждого тела. Пересчёт делается через обратное вращение
|
||
(`rotation().conjugate() * (world - translation)`). Ошибка здесь не бросает
|
||
исключение: соединение просто «приклеивается» к другой точке и уезжает при
|
||
первом же движении.
|
||
|
||
### B2.6.2. Что показали измерения, а не рассуждения
|
||
|
||
1. **Констрейн не «стягивает» тела — и это правильно.** Якорь в мировой точке
|
||
по определению совпадает у обоих тел в момент создания, поэтому
|
||
ограничение выполнено сразу и «включается» только когда тела расходятся.
|
||
Первые пять тестов проверяли «расстояние между телами» и падали именно
|
||
поэтому. Правильный инвариант — **совпадение точек крепления** под
|
||
действием силы.
|
||
|
||
2. **Сила в Rapier действует один шаг** и сбрасывается после интегрирования:
|
||
`add_force` надо вызывать каждый кадр. Тест, подававший силу один раз,
|
||
измерял не силу, а её отсутствие — и «падал» одинаково при 8000 и при
|
||
40 000.
|
||
|
||
3. **Спящее тело честно сообщает нулевую скорость.** Проверять эффект силы по
|
||
скорости бессмысленно, если тело уснуло на полу: корректный тест сравнивает
|
||
прогон с силой и контрольный прогон без неё.
|
||
|
||
4. **Ползун (`phys_slider`) не удерживает.** Сустав сообщает `enabled = true` и
|
||
5 заблокированных осей из 6, шар/шарнир/сварка в том же прогоне держат, но
|
||
ползун пропускает расхождение по высоте при толчке вдоль оси. Тест помечен
|
||
`#[ignore]` с причиной — зелёная галочка на неработающем коде хуже, чем
|
||
явный отказ. Гипотеза к проверке: в 0.36 ползун требует согласованности
|
||
`local_axis1`/`local_axis2` иначе, чем задаёт их `new(axis)`.
|
||
|
||
### B2.6.3. Границы
|
||
|
||
- Констрейны соединяют только **тела физики**: `prop_physics` и объекты,
|
||
созданные скриптом. У `prop_static` тела нет.
|
||
- Оси соединения задаются в мировых координатах, как в GMod.
|
||
- Ограничение угла переводится в радианы; вырожденный диапазон игнорируется.
|
||
- `pulley` и `thruster` — это не суставы: тяга делается через `add_force`.
|
||
|
||
## Часть B3. Проваливание сквозь пол: что проверено и что нет
|
||
|
||
### B3.1. Две гипотезы, обе опровергнуты измерением
|
||
|
||
1. **«Пропов нет в коллизии игрока».** Гипотеза была верной *как диагноз*,
|
||
но не как объяснение жалобы: игрок ходил по брашам. Пробел устранён
|
||
(`Collision::boxes`), проп теперь стоит под ногами — это нужно в любом
|
||
случае, но от проваливания по брашам не избавило.
|
||
2. **«На низком FPS шаг подшага прошивает пол».** Опровергнуто тестом
|
||
`fast_fall_does_not_tunnel_thin_floor`: при terminal-скорости 2000
|
||
юнитов/с и dt = 1/60 игрок садится на площадку толщиной **4 юнита** и
|
||
`on_ground = true`. Подшагов восемь хватает.
|
||
|
||
Обе версии выглядели правдоподобно и обе были неверны — характерная черта
|
||
дефектов, которые видны только после попытки воспроизвести их тестом.
|
||
|
||
### B3.2. Что измерено
|
||
|
||
- Стояние на спавне gm_construct: 180 кадров, `z = -143.9` стабильно,
|
||
`on_ground = true`, провала нет.
|
||
- Значит дефект локальный и воспроизводится на определённой геометрии.
|
||
|
||
### B3.3. Что нужно, чтобы найти причину
|
||
|
||
Точное место и обстоятельства: с какой высоты, после какого движения
|
||
(прыжок, спуск по рампе, спуск с края) и на каком участке. Без этого
|
||
пришлось бы перебирать геометрию карты наугад — а это ровно тот способ,
|
||
которым эти две гипотезы и были порождены.
|
||
|
||
## Часть B4. Физика движения: как в Source
|
||
|
||
### B4.0. Корневая причина четырёх жалоб сразу
|
||
|
||
Симптомы, описанные как разные (проталкивает на расстояние, метания вдоль
|
||
стены, сползание по рампам, bhop), оказались **одной ошибкой**:
|
||
|
||
```rust
|
||
self.velocity.z -= GRAVITY * dt; // выполнялось КАЖДЫЙ кадр
|
||
```
|
||
|
||
Гравитация применялась и на земле. Каждый кадр игрок вдавливался в
|
||
геометрию, а разрешение столкновений выталкивало его обратно. Отсюда всё:
|
||
«выталкивает на определённое расстояние» — это выталкивание из вдавливания;
|
||
«то за стену, то обратно» — борьба позиции с выталкиванием; «сползает по
|
||
рампе» — вертикаль всё время возвращается вниз.
|
||
|
||
Source делает наоборот: при `FL_ONGROUND` вертикальная скорость
|
||
обнуляется, а `ApplyGravity` в земле **не вызывается вовсе**.
|
||
|
||
### B4.1. Что изменено
|
||
|
||
- **Гравитация только в воздухе.** На земле `velocity.z = 0`.
|
||
- **Трение всегда на земле**, а не только при отсутствии ввода: Source
|
||
вызывает `Friction()` каждый кадр на земле — именно оно держит игрока на
|
||
рампе. Константы `sv_friction = 4`, `sv_stopspeed = 100`.
|
||
- **Прыжок по фронту нажатия.** Защёлка `jump_latched` держится, пока пробел
|
||
зажат; без неё удержание давало бесконечный bhop.
|
||
- **Подшаг `PM_StepSlideMove`**: полное движение, при блокировке — повтор с
|
||
подъёмом на `STEP_HEIGHT = 18`, затем спуск. Принимается только если
|
||
спуск встал на опору, иначе это залезание на стену. При падении
|
||
подшаг отключён — иначе в прыжке игрёт в потолок.
|
||
- `MoveResult::blocked` — признак упёрся в геометрию, нужен подшагу.
|
||
|
||
### B4.2. Ошибки в моих же правках, пойманные тестами
|
||
|
||
1. `jump_latched = jump` — защёлка вставала на первом же кадре, ещё **до**
|
||
первого прыжка, и прыгнуть было нельзя вообще.
|
||
2. `jump_latched = jump && self.on_ground` — флаг сбрасывался тем же кадром,
|
||
когда прыжок уже отработал (`on_ground` сброшен выше), bhop возвращался.
|
||
3. Тест на рамку мерил **первоначальное падение** (40 → 6), а не сползание,
|
||
и падал с «сносом 34». После отделения приземления снос — 0.
|
||
|
||
### B4.3. Тесты
|
||
|
||
- `standing_on_a_slope_does_not_slide_down` — снос после приземления.
|
||
- `holding_jump_does_not_bunnyhop` — ровно один прыжок за 300 кадров.
|
||
- `player_steps_up_a_curb` — переступает бордюр 12 юнитов.
|
||
- `player_stands_on_a_dynamic_body` — стоит на пропе.
|
||
|
||
## Часть B5. Осталось по физике движения
|
||
|
||
- **Воздушная мобильность.** `AIR_ACCEL = 12` при `GROUND_ACCEL = 10` — в
|
||
воздухе быстрее, чем на земле. В Source ускорение общее
|
||
(`sv_accelerate = 10`), а в воздухе `sv_air_max_wishspeed = 30` ограничивает
|
||
прирост, и трения в воздухе нет. Нужен Quake-алгоритм `Accelerate`.
|
||
- **Выталкивание из стен.** Сейчас при упоре в стену игрока выталкивает;
|
||
в Source движение просто останавливается.
|
||
- `sv_maxspeed = 320`, `sv_maxvelocity = 350` как отдельные ограничения.
|
||
- Ускорение/торможение поворота камеры, инерция при стрейфе.
|
||
|
||
## Часть B6. Изучение `CGameMovement` (Source SDK 2013)
|
||
|
||
Источник: `src/game/shared/gamemovement.cpp` из `ValveSoftware/source-sdk-2013`
|
||
(доки Valve под Anubis, читался исходник). Ниже — расхождения нашей реализации
|
||
с оригиналом, найденные **чтением первоисточника**, а не догадками.
|
||
|
||
### B6.1. Почему теряется скорость на рампе вверх
|
||
|
||
`CGameMovement::StepMove` устроен принципиально иначе, чем наш подшаг:
|
||
|
||
```cpp
|
||
// Try sliding forward both on ground and up 16 pixels
|
||
// take the move that goes farthest
|
||
TryPlayerMove( &vecEndPos, &trace ); // сначала ВНИЗ — получаем vecDownPos/vecDownVel
|
||
...подъём на flStepSize...
|
||
TryPlayerMove(); // потом ВВЕРХ — получаем vecUpPos
|
||
// decide which one went farther
|
||
float flDownDist = ( vecDownPos.x - vecPos.x )^2 + ( vecDownPos.y - vecPos.y )^2;
|
||
float flUpDist = ( vecUpPos.x - vecPos.x )^2 + ( vecUpPos.y - vecPos.y )^2;
|
||
if ( flDownDist > flUpDist ) { едем вниз; }
|
||
else { /* copy z value from slide move */ mv->m_vecVelocity.z = vecDownVel.z; }
|
||
```
|
||
|
||
Три отличия от нашего кода, каждое даёт симптом:
|
||
|
||
1. **Пробуются оба варианта, побеждает тот, что ушёл дальше по горизонтали.**
|
||
Мы всегда идём путём «поднять на 18 и уронить». На рампе вверх путь вниз
|
||
(без подшага) уходит дальше — значит оригинал его и берёт, а подшаг не
|
||
выполняется вовсе. У нас он выполняется всегда и режет путь.
|
||
2. **Вертикальная скорость берётся из движения ВНИЗ**
|
||
(`m_vecVelocity.z = vecDownVel.z`), а не из движения вверх. Иначе на
|
||
рампе появляется паразитная вертикальная составляющая.
|
||
3. Отказ от подшага при `trace.plane.normal[2] < 0.7` — то есть на крутых
|
||
поверхностях подшаг не применяется вовсе.
|
||
|
||
### B6.2. Почему при спуске «прыгает с рампы»
|
||
|
||
Наш подшаг выполняется по признаку «движение заблокировано» и всегда с
|
||
подъёмом на `STEP_HEIGHT = 18`. На рампе вниз игрок кадр за кадром
|
||
подскакивает на 18 юнитов и падает обратно — визуально это «прыжки с
|
||
рампы». Оригинал при спуске идёт по пути `flDownDist`, который дальше, и
|
||
подшага не делает.
|
||
|
||
### B6.3. Трение: у нас не так
|
||
|
||
`CGameMovement::Friction()` вызывается **каждый кадр на земле** безусловно
|
||
(проверка только `GetGroundEntity() != NULL`), а не «если нет ввода».
|
||
Формула оригинала:
|
||
|
||
```cpp
|
||
control = (speed < sv_stopspeed) ? sv_stopspeed : speed; // 100 по умолчанию
|
||
drop += control * friction * frametime; // sv_friction = 4
|
||
newspeed = max(0, speed - drop);
|
||
```
|
||
|
||
У нас трение срабатывает только при `wish.length_squared() < 1.0`, то есть
|
||
при беге по рампе его нет вовсе.
|
||
|
||
### B6.4. Гравитация делится пополам до и после движения
|
||
|
||
```cpp
|
||
void CGameMovement::StartGravity( void ) {
|
||
mv->m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * 0.5 * frametime;
|
||
}
|
||
void CGameMovement::FinishGravity( void ) {
|
||
mv->m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * frametime * 0.5;
|
||
}
|
||
```
|
||
|
||
В комментарии Valve: «yes, this 0.5 looks wrong, but it's not». Суммарно
|
||
гравитация та же, но момент приложения другой — это и даёт правильное
|
||
ощущение веса на дуге.
|
||
|
||
### B6.5. Ускорение: Quake-алгоритм, а не линейная интерполяция
|
||
|
||
```cpp
|
||
currentspeed = velocity.Dot(wishdir);
|
||
addspeed = wishspeed - currentspeed;
|
||
if (addspeed <= 0) return;
|
||
accelspeed = accel * frametime * wishspeed * friction; // НЕ accel*dt!
|
||
if (accelspeed > addspeed) accelspeed = addspeed;
|
||
velocity += accelspeed * wishdir;
|
||
```
|
||
|
||
`AirAccelerate` отличается одной строкой — ограничением желаемой скорости:
|
||
|
||
```cpp
|
||
if ( wishspd > GetAirSpeedCap() ) wishspd = GetAirSpeedCap(); // sv_air_max_wishspeed = 30
|
||
```
|
||
|
||
Вот почему в воздухе управление «вязкое»: прирост скорости за кадр
|
||
ограничен, а ускорение не накапливается. У нас `AIR_ACCEL = 12` при
|
||
`GROUND_ACCEL = 10` и линейная интерполяция к целевой скорости — в воздухе
|
||
у нас **быстрее**, чем на земле.
|
||
|
||
### B6.6. Прыжок по фронту
|
||
|
||
В чистом Source `IN_JUMP` проверяется по уровню, но при выходе из воды
|
||
ставится `mv->m_nOldButtons |= IN_JUMP; // Don't jump again until released`.
|
||
То есть «не прыгать, пока не отпустят» — штатный приём движка, а не
|
||
изобретение: наша защёлка `jump_latched` воспроизводит именно его.
|
||
|
||
### B6.7. Порядок работы на будущее
|
||
|
||
1. `StepMove` по оригиналу: оба варианта, побеждает дальний, `z` из
|
||
вниз. Чинит и потерю скорости, и «прыжки с рампы».
|
||
2. `Friction()` безусловно на земле.
|
||
3. `StartGravity` / `FinishGravity` пополам.
|
||
4. `Accelerate` / `AirAccelerate` квото-формулой с `sv_air_max_wishspeed`.
|
||
5. `CheckVelocity` — ограничиватель `sv_maxvelocity = 350`.
|
||
|
||
## Часть B7. Переписано движение по `CGameMovement`: коллизия тела
|
||
|
||
Главный дефект батча 2 был не в кинематике, а в самой проверке пересечения
|
||
AABB с кирпичом. Её переписали; кинематику трогали только по оригиналу.
|
||
|
||
### B7.1. Глубина проникновения считалась не по той грани
|
||
|
||
Формула была `pen = dist - project_min(aabb, n)` — «насколько тело зашло за
|
||
грань с внутренней стороны». Для игрока, стоящего на полу (низ на 1 юнит ниже
|
||
верха кирпича высотой 100), она даёт **172 юнита проникновения в нижнюю
|
||
грань** — на 172 юнита *выше* неё. Тело выталкивало вниз, сквозь пол.
|
||
|
||
Отсюда все три жалобы сразу:
|
||
|
||
| Жалоба | Причина |
|
||
|---|---|
|
||
| «поверхность превратилась в лёд» | тело улетало вниз, `on_ground` становился ложным, трение не применялось |
|
||
| «выталкивает сквозь пол» | то же, плюс игрок проваливался на сотни юнитов |
|
||
| «выталкивает сквозь стены» | см. B7.2 |
|
||
|
||
Заменено на SAT по разделяющим осям: длина перекрытия интервалов проекций,
|
||
`min(a1, b1) - max(a0, b0)`. Для того же игрока — 1 юнит, выход вверх.
|
||
|
||
Чтобы у кирпича был второй конец интервала, пришлось найти его вершины
|
||
(пересечения пар граней) и хранить проекцию противоположного края — `plane_min`.
|
||
|
||
### B7.2. Минимальный вектор выхода не годится для тонкой стены
|
||
|
||
Даже с правильной глубиной SAT выбирает выход по **меньшему** вектору. У стены
|
||
шириной 16 при теле шириной 32 выход вперёд всегда короче выхода назад: тело,
|
||
дошедшее до середины стены, выталкивается **сквозь неё**. Замерено ровно до
|
||
`x = 132.1` при стене на `x = 100`.
|
||
|
||
Поэтому добавлена swept-трассировка (`clip_fraction`) — тело останавливается
|
||
у грани и никогда не оказывается по другую сторону тонкой стены. Грань
|
||
«раздувается» на полугабариты тела; для плоскости сумма Минковского точна.
|
||
|
||
### B7.3. Застревание в рампе
|
||
|
||
Замерено на настоящей карте: игрок намертво замирал на 45° рампе — позиция и
|
||
скорость не менялись кадрами. Причина: контакт с рамкой гасил гравитацию
|
||
вдоль нормали (`clip_velocity`), а `on_ground` оставался ложным, потому что в
|
||
пути отсечения опора не выставлялась. Равновесие без движения.
|
||
|
||
### B7.4. `StayOnGround` — функции не было вообще
|
||
|
||
В `CGameMovement` после `WalkMove` **всегда** вызывается `StayOnGround()`:
|
||
трассировка вверх на 2 юнита, затем вниз на `m_flStepSize`, и если под ногами
|
||
есть опора с `normal[2] >= 0.7` — игрок притягивается к поверхности. Без этого
|
||
на спуске он висит над землёй (гравитация на `FL_ONGROUND` отключена и
|
||
возвращать его нечем), а на рампе не притягивается.
|
||
|
||
Порог опоры — `sv_walkable_normal` = **0.7**, а не 0.6.
|
||
|
||
### B7.5. `StepMove` только с земли
|
||
|
||
`WalkMove` вызывается только при `GetGroundEntity() != NULL`, иначе `AirMove`.
|
||
`StepMove` вызывается из `WalkMove` только при блокировке. То есть подшаг на
|
||
ступеньку в прыжке не работает — у нас работало.
|
||
|
||
### B7.6. «Сквозь рампу я прошёл вообще»: 87% кирпичей не имело объёма
|
||
|
||
`vertices_of` брала пересечение двух граней и выбирала точку, ближайшую к
|
||
началу координат. Но пересечение двух граней — это **прямая**, а не точка:
|
||
выбранная точка оказывалась вне кирпича и отбрасывалась. У ящика из шести граней
|
||
ни одной вершины так не находилось вовсе.
|
||
|
||
Замер на gm_construct: **1846 кирпичей из 2126 (87%) считались «без объёма»**,
|
||
то есть не твёрдыми: сквозь них можно было пройти, стоять на них было не на чем.
|
||
Это и есть корень жалоб «сквозь рампу прошёл» и «падаю сквозь карту».
|
||
|
||
Починка: по каждой паре непараллельных граней строится сама прямая, затем
|
||
обрезается всеми полупространствами кирпича — концы отрезка и есть вершины.
|
||
После починки «без объёма» осталось 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`.
|
||
|
||
## Часть E6. Небо за границами карты (сделано)
|
||
|
||
### E6.1. Жалоба и измерение
|
||
|
||
> «если игрок выходит за карту, то скайбокс и 3D-скайбокс не должны рендериться
|
||
> (иначе не понятны границы карты)»
|
||
|
||
Причина видна прямо в шейдере 3D-скайбокса:
|
||
|
||
```wgsl
|
||
let world = camera.pos.xyz + (in.pos - sky.origin_scale.xyz) * sky.origin_scale.w;
|
||
```
|
||
|
||
Геометрия неба размещается **относительно камеры**, поэтому небо всегда
|
||
заливает весь фон: куда ни вылети, оно рядом. Пределы карты на фоне не видно
|
||
не из-за того, что неба «слишком много», а из-за того, что оно не привязано к
|
||
миру вовсе.
|
||
|
||
Замер на gm_construct:
|
||
|
||
| | значение |
|
||
|---|---|
|
||
| Габариты геометрии карты | `(-5640, -4576, -960) .. (2168, 6464, 2976)` |
|
||
| Габариты коллизии | `(-15360, -15360, -1024) .. (15872, 15360, 15359)` |
|
||
| `sky_camera` | `(-1428, 1645, 10991)`, масштаб 1/16 |
|
||
| Границы 3D-скайбокса | `(-14848, -14336, 10654) .. (15360, 14848, 12520)` |
|
||
| 2D-скайбокс (`SKYBOX`) | отсутствует, только 3D |
|
||
| Спавн | `(704, 132, -143)` |
|
||
|
||
Видно, почему габариты коллизии не годятся: они включают и 3D-небо, и далёкие
|
||
террасы. Основание — габариты **геометрии карты** (`World::bounds_min/max`),
|
||
они же считаются по вершинам видимых поверхностей.
|
||
|
||
### E6.2. Решение
|
||
|
||
`World::inside_bounds(pos, margin)` и свободная функция `world::inside_bounds` —
|
||
одна реализация, которой пользуются и движок, и рендерер (рендерер копирует
|
||
габариты при загрузке, а не хранит ссылку на мир).
|
||
|
||
`WorldRenderer::update_sky_visibility` пересчитывает видимость **каждый кадр**
|
||
из позиции камеры: игрок уходит за край уже во время игры. Проверка идёт по всем
|
||
трём осям сразу: под картой небо тоже исчезает, а не только сбоку.
|
||
|
||
`SKY_BOUNDS_MARGIN = 128` — допуск наружу. Габариты считаются по вершинам, а
|
||
камера стоит на высоте глаз (около 64 юнитов) над опорой, так что стоящий на самом
|
||
краю игрок формально за габаритом на весь свой габарит.
|
||
|
||
### E6.3. Тесты
|
||
|
||
- `sky_is_visible_only_inside_the_map` — юнит на сам предикат, включая ровно на
|
||
границе с допуском и на единицу за ней.
|
||
- `real_map_sky_bounds` (`--ignored`) — на настоящей карте: спавн внутри, за
|
||
край по любой из шести осей небо гаснет.
|
||
|
||
Оба прохода неба (2D-фон и 3D-геометрия) гасятся одним и тем же флагом: иначе
|
||
при вылете за край остался бы виден 3D-неб, а это ровно то, что мешало увидеть
|
||
границу.
|
||
|
||
## Часть B6.8. Гравитация пополам и `CheckVelocity` (сделано)
|
||
|
||
Два оставшихся пункта из B6.7, сверенные с оригиналом
|
||
(`src/game/shared/gamemovement.cpp`).
|
||
|
||
### B6.8.1. Гравитация делится до и после движения
|
||
|
||
`FullWalkMove` начинается с `StartGravity()` и заканчивается `CheckJump`, который
|
||
зовёт `FinishGravity()`. Обе берут ровно половину гравитации:
|
||
|
||
```cpp
|
||
// StartGravity
|
||
m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * 0.5 * frametime;
|
||
// FinishGravity — Get the correct velocity for the end of the dt
|
||
m_vecVelocity[2] -= ent_gravity * GetCurrentGravity() * frametime * 0.5;
|
||
```
|
||
|
||
Комментарий Valve прямо отмечен: «yes, this 0.5 looks wrong, but it's not».
|
||
Половина добавляется, чтобы игрок оказался в правильной позиции **во время**
|
||
движения, вторая — чтобы получить вертикальную скорость на конец кадра.
|
||
|
||
Проверено, что `TryPlayerMove` гравитацию не применяет: её зовут только
|
||
`StartGravity` / `FinishGravity` вокруг движения, так что «гравитация на каждое
|
||
касание» — не тот механизм.
|
||
|
||
Замер высоты прыжка на gm_construct после деления: **42,7 -> 44,9 юнита** при
|
||
~45 в Source. Падение начинается на кадр раньше, а не на кадр позже.
|
||
|
||
### B6.8.2. `CheckVelocity`: `sv_maxvelocity` попоосно
|
||
|
||
```cpp
|
||
for (i=0; i < 3; i++) {
|
||
if (IS_NAN(m_vecVelocity[i])) m_vecVelocity[i] = 0;
|
||
if (m_vecVelocity[i] > sv_maxvelocity) m_vecVelocity[i] = sv_maxvelocity;
|
||
if (m_vecVelocity[i] < -sv_maxvelocity) m_vecVelocity[i] = -sv_maxvelocity;
|
||
}
|
||
```
|
||
|
||
Потолок — 350 по каждой оси. Без него шаг за кадр на большой скорости
|
||
становится длиннее стены, отсечение её перестаёт видеть, и игрок проскакивает
|
||
сквозь тонкие стены и пол.
|
||
|
||
Отдельно важно **место** вызова: в оригинале `CheckVelocity()` сидит прямо
|
||
перед `TryPlayerMove()` (внутри `Friction()`), то есть скорость приводится в
|
||
порядок до расчёта смещения. Наш вариант сначала звал его только в конце кадра —
|
||
одна нечислица в скорости уходила в смещение, отсечение её не видело, и позиция
|
||
становилась нечислицей **навсегда**: прогон тестов на этом занимал 42 секунды
|
||
вместо 0,01. Теперь вызов есть и перед движением, и в конце.
|
||
|
||
Позиция проверяется на нечислицу так же, как скорость (в оригинале — тоже,
|
||
с обнулением оси на 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.
|