Files
my-world/README.md
T
aibrashandClaude Sonnet 5.5 db437afd7a Название Aman's World: заставка загрузки, иконка, заголовок вкладки
- branding/splash.png, branding/icon.svg; project.godot: config/name, boot_splash, config/icon
- user:// остаётся прежним (custom_user_dir_name="world2"), чтобы не потерять профиль и рекорды

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
2026-10-29 12:09:33 +05:00

478 lines
84 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# My World
Мультиплеерная игра от первого лица в браузере (Godot 4.7.2, Web). Игроки собираются в стартовой
комнате-хабе, заходят через порталы на разные карты и убегают там от преследователей — камня и Верити.
Паркур-управление: спринт, рывок, подкат, присед, бег по стенам, подтягивание на уступы.
- **Движок:** Godot **4.7.2**, рендерер **GL Compatibility** (нужен для Web), физика **Jolt**
- **Платформа:** Web (WebAssembly), без потоков (`thread_support=false`), без GDExtension (`extensions_support=false`)
- **Язык** комментариев в коде, интерфейса и документации: русский
- **Репозитории** (соседние папки в `/home/aibrash/Projects/My_World/`):
`my-world` — этот проект; `grid-world` — готовый веб-экспорт, который CI выкладывает на сайт;
`Blender models` — исходники моделей; `source-assets` — исходные файлы вне игры (например, видео, из которого сделан звук Верити)
## Состояние: что есть и чего нет
| | Что |
|---|---|
| ✅ | Хаб с порталами; карты **Песочница**, **Backrooms**, **Parkour** и **Race**; выход из карт обратно в хаб |
| ✅ | **Race** — гонка на ногах до 8 игроков (отсчёт, ворота по порядку, места, итоги, рекорд, песня на старте); **Parkour** — подъём по мегабашне с чекпоинтами, лазерами, дождём и песней; обе карты — мрачная киберпанк-антиутопия, свет запечён в Blender (см. «Карты и порталы», «Карта Race», «Заметки для будущего меня») |
| ✅ | Вкладка в браузере занимает **~300 МБ** (замер владельца, окт 2026) |
| ✅ | Карты качаются отдельными пакетами `.pck` при первом входе в портал (экран загрузки с прогрессом) |
| ✅ | Мультиплеер: выделенный сервер, игроки видят друг друга, **комнаты по картам** (в разных картах не пересекаются) |
| ✅ | Преследователи: **камень** и **Верити**; общие для всех игроков комнаты, ходят по навмешу, «видят» только вблизи или в поле зрения, могут потерять игрока |
| ✅ | Смерть при поимке и возрождение, красный экран «ПОЙМАЛ!» |
| ✅ | Паркур: спринт, рывок, подкат, **присед (C)**, бег по стенам, прыжок от стены, двойной прыжок, подтягивание на уступ, зум (ЛКМ) |
| ✅ | Управление **не зависит от раскладки** (русская и любая другая): действия `InputMap` на физические клавиши |
| ✅ | Деплой: CI из `grid-world` на сайт; скрипты экспорта, dev-сервера, тестов без окна, выкладки сервера |
| ❌ | Управление с помощью геймпада |
| ✅ | **Ники** (панель по N, подпись над игроками, в итогах гонки) и **общий рейтинг** лучших времён на сервере (топ-10 на Race/Parkour, таблица по Tab, доски в хабе, таймер и итоги Parkour, сдача в гонке на G) — раздел «Ники и рейтинг» |
| ❌ | Текстовый чат — **запланирован**, не сделан (план в «Дорожная карта → Ближайшее») |
| ❌ | Настроек (чувствительность мыши, громкость), меню паузы |
| ❌ | Серверных проверок (антиспуфинг позиций, античит): сервер только пересылает |
| ❌ | Сохранения прогресса, аккаунтов |
| ❌ | Автоматических тестов в CI (есть боты для ручного прогона, см. «Тесты без окна») |
| ✅ | Чужой игрок — модель киберниндзя со скелетом (`player/character/character.glb`, 752 треугольника, 17 костей, 14 анимаций); свечение красится в цвет игрока от его id. Своё тело — та же модель, спрятанная от своей камеры |
## Быстрый старт
```bash
# запуск игры локально (стартует в хабе; без сервера — одиночная игра, клиент переподключается каждые 3 с)
godot --path . --windowed --resolution 960x540
# с локальным сервером (в другом терминале)
tools/test/make_test_server.sh /tmp/test-server && godot --headless --path /tmp/test-server
godot --path . # можно запустить несколько окон
```
Карты в редакторе запускаются отдельно (F6 на сцене карты): `maps/sandbox/sandbox.tscn`,
`maps/backrooms/backrooms.tscn`, `maps/parkour/parkour.tscn`, `maps/race/race.tscn`. В редакторе и при запуске с диска порталы ведут на сцены напрямую (пакеты не качаются).
## Управление
Клавиши привязаны к **физическому расположению** (так работает и на русской раскладке). Действия лежат в
`project.godot → [input]`: `move_forward/back/left/right`, `jump`, `sprint`, `crouch`, `dash`, `ready` (R — «готов» в гонке, перезапуск забега в Parkour), `nick` (N), `leaderboard` (Tab), `giveup` (G).
| Клавиша | Действие |
|---|---|
| W A S D, стрелки | движение |
| Shift | спринт |
| Пробел | прыжок; держать у уступа в воздухе — подтянуться; у стены — прыжок от стены; в воздухе — двойной прыжок |
| C (держать) | **присед** (скорость 3,5, капсула вдвое ниже, пролезает под низкое) |
| Shift + C на бегу | **подкат** |
| Q | рывок |
| R | «готов» (карта Race); на карте Parkour — **забег заново** (на старт, таймер сброшен) |
| G (держать 1,5 с) | **сдаться** в гонке (только пока идёт гонка; отпустил раньше — отмена) |
| N | ввод/смена ника (панель по центру; Enter — подтвердить, Esc — пропустить/отмена); на время ввода игрок стоит |
| Tab | таблица лидеров текущей карты (топ-10 и «Вы: N-й»); в хабе и на картах без рейтинга — вкладки по картам, A/D или стрелки; повторный Tab или Esc закрывают |
| ЛКМ (зажать) | зум |
| Esc / клик | отпустить / захватить мышь |
Бег по стене — в воздухе у стены с зажатым «вперёд» на скорости. Ctrl не используется: в браузере `Ctrl+W` закрывает вкладку.
## Структура проекта
```
project.godot, export_presets.cfg, icon.svg
net/ сеть. Net — автолоад: и клиент, и сервер (один скрипт)
net.gd состояние комнат, преследователей, RPC, профиль и ники, рейтинг на сервере (см. «Сеть», «Ники и рейтинг»)
ghost.gd чужой игрок: модель, выбор анимации по флагам и скорости, цвет свечения от id, ник
player/character/ character.glb — модель и анимации (исходник и скрипт сборки: `../Blender models/character_01/`)
server.tscn пустая сцена для запуска выделенного сервера
core/
map_loader.gd автолоад MapLoader: скачивает пакет карты и подключает его (см. «Загрузка карт»)
overlay.gd автолоад Overlay: панель ника (N) и таблица лидеров (Tab) поверх любой карты
lb_view.gd LbView: таблица рейтинга карты (панель Tab, итоги Race/Parkour, будущая Metro) и общий стиль панелей
player/
player.tscn, player.gd игрок от первого лица: всё паркур-управление, смерть и возрождение
objects/
portal.gd портал на другую карту (Area3D, рисуется кодом)
chasers/
chaser.gd общий «мозг» преследователя: навмеш, зрение, погоня, блуждание, поимка, синхронизация
rock/ камень: rock.tscn, Rock.glb (+ извлечённая текстура), Guitar.ogg
verity/ Верити: verity.tscn, verity2.glb (+ текстура), verity.ogg (звук из видео)
maps/ у каждой карты своя папка — она самодостаточна и экспортируется отдельным пакетом
hub/ стартовая комната: hub.tscn, hub.gd (порталы строятся из списка PORTALS)
sandbox/ песочница: sandbox.tscn, forest.gd, birds.gd, levitate.gd, nav_bake.gd,
floor/ sky/ audio/ models/ (здания, башня, обелиск, деревья, Эмпайр-стейт…)
race/ race.tscn, race.gd, race_rules.gd (правила гонки), race_data.gd (генерируется), race.glb, sky.png, audio/music.ogg
parkour/ parkour.tscn, parkour.gd (логика: чекпоинты, лазеры, дождь, звук, окружение), parkour_data.gd (генерируется),
baked.gdshader, parkour.glb (из Blender), sky.png (панорама), audio/music.ogg; см. «Заметки для будущего меня»
backrooms/ backrooms.tscn, backrooms.gd, backrooms_nav.res (запечённый навмеш),
model/ (готовая модель «Backrooms VR» + 27 извлечённых текстур + CREDITS.md)
deploy/ боевой конфиг nginx, systemd-юнит, project.godot выделенного сервера
tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (боты)
```
**Правила, которые стоит соблюдать**
- **Папки и файлы — в `snake_case`.** Каждая карта — в `maps/<имя>/`. Всё, что нужно только ей, лежит внутри.
Общее (игрок, преследователи, порталы, сеть) — вне `maps/`: оно входит в основной пакет игры.
- **Файлы рядом с `.glb` не переименовывать и не удалять.** Это текстуры, которые Godot вынул из модели при импорте
(`gltf/embedded_image_handling=Extract`), их имена зависят от имени `.glb`. Переименуешь `.glb` — импорт создаст дубли.
Папки с моделями переносить целиком можно (я так и делал).
- **`.import` и `.uid` коммитятся** и переносятся вместе с файлом (`git mv`). Кэш `.godot/` не коммитится; после переноса
файлов вне редактора его удаляют и делают `godot --headless --import --path .`.
- Пути в сценах — через `res://`; ссылки на карты (`PORTALS` в `hub.gd`, `target_scene` у порталов) — строками.
## Карты и порталы
| Карта | Сцена | Пакет | Что там |
|---|---|---|---|
| Хаб | `maps/hub/hub.tscn` | в основном пакете | Комната 28×18×7 м, строится кодом (пол-грид, стены, свет). Порталы в ряд по центру, влезает 6. Над порталами карт «на время» — доски с топ-3 (см. «Ники и рейтинг»). Безопасна, преследователей нет |
| Песочница | `maps/sandbox/sandbox.tscn` | `world.pck` | Киберпанк-мир: пол-шахматка, здания, 60 деревьев (MultiMesh), птицы, парящий обелиск, звук `tron_01`. Преследователи: **камень** и **Верити**. Портал «HUB» возвращает в хаб |
| Backrooms | `maps/backrooms/backrooms.tscn` | `backrooms.pck` | Модель «Backrooms VR» (×1,4). Преследователь: **Верити** (масштаб 0,5). Выход «EXIT» в дальнем конце ведёт в хаб. Гул ламп генерируется кодом |
| Parkour | `maps/parkour/parkour.tscn` | `parkour.pck` (7,4 МБ: небо 3,5 + модель 3,4 + музыка 1,1) | Мрачная антиутопия в духе Ghostrunner: дождь, дымный воздух, грязный мокрый бетон, натриевый оранжевый, тревожный красный и больной зелёный свет, надписи-приказы (COMPLY, CURFEW 22:00, RESTRICTED). **Забег на время** (старт — линия на краю стартовой платформы, финиш — вершина; рейтинг на сервере; R — заново). Подъём по мегабашне, ~700 м, 9 чекпоинтов (светящиеся ворота), без преследователей. Секции: крыши (одинарный/двойной прыжок, подтягивание) → каньон из четырёх стен для бега → башня из 9 уступов (двойной прыжок + подтягивание) → лазерный зал (подкат, прыжок, тайминг, рывок) → пропасть из 6 контейнеров (зазоры 16–24 м) → спираль вокруг светящегося ядра (+24 м) → вершина с порталом в хаб. Срыв ниже последнего чекпоинта на 32 м или касание лазера = «СОРВАЛСЯ!» и возрождение на чекпоинте. Музыка: `maps/parkour/audio/music.ogg` (Ogg Vorbis, моно 32 кГц, ~1 МБ; делается `ffmpeg -i исходник.mp3 -ac 1 -ar 32000 -c:a libvorbis -q:a 0 music.ogg`), играет по кругу |
| Race | `maps/race/race.tscn` | `race.pck` | Гонка на ногах до 8 игроков (кто быстрее до финиша), мрачный город: стартовая площадка → рынок → эстакада → крыши → каньон → ангар. 8 ворот-чекпоинтов, 2 лазера, развилки с обходами. Правила и сеть — раздел «Карта Race» ниже |
**Правка и пересборка Parkour** (всё в `Blender models/parkour_02/`):
1. `layout.py` — единственный источник: плиты, стены, лазеры, чекпоинты и расчёт зазоров под мувсет (замеры ботом: прыжок на спринте 15 м, двойной 25 м, прыжок+рывок 19 м, двойной+рывок 28 м, бег по стене 1,8 с / 23 м, подтягивание на уступ до ~2,6 м; **стенами высоту не набрать** — прыжков от стены без бега только 2 за полёт).
2. `build_parkour.py` — в Blender (`exec`): строит коллизию (`Collision-colonly` — Godot делает из неё StaticBody), детальную геометрию `Solid`, светящиеся элементы `Neon`; пишет `parkour_data.gd` (чекпоинты, лазеры, старт, портал). Сохраняет `Parkour_02.blend`.
3. `bake.py` — `blender -b Parkour_02.blend -P bake.py`: Cycles запекает прямой+непрямой свет (неон светит сам, плюс холодный/пурпурный/бирюзовый свет) в **цвета вершин** (сжаты в 8 раз — шейдер умножает обратно) и экспортирует `parkour.glb`. ~1 мин.
4. `build_sky.py` — `blender -b -P build_sky.py`: панорама дальнего города 3072×1536 → `sky.png` (в импорте включены мипы — по ним размытые отражения). ~2 мин.
5. `godot --headless --import --path .`.
Палитра светящихся задана именами материалов из Blender (старые названия сохранены): `NeonCyan` — холодный белёсый, `NeonMagenta` — красный, `NeonOrange` — натриевая лампа. Настроение — в `parkour.gd` (туман, дождь `_add_rain`, гул `_add_ambience`, насыщенность и контраст), в `bake.py` (силы света), `build_sky.py` (дымное небо).
В Godot: `baked.gdshader` (unshaded: цвет материала × запечённый свет × «зерно» бетона по мировым координатам + влажный блеск — отражение панорамы на горизонтальных поверхностях); `parkour.gd` подменяет материалы по именам из Blender (`SURFACES`, `GLOWS`), строит ворота-чекпоинты, лазеры (пульсирующие считают фазу по системным часам), окружение и портал.
**Добавить карту:**
1. Создай `maps/<имя>/<имя>.tscn`: корень Node3D, внутри игрок (`player/player.tscn`), портал обратно (`objects/portal.gd`,
`target_map="hub"`, `target_scene="res://maps/hub/hub.tscn"`), при необходимости преследователи.
В `_ready()` вызови `Net.enter_map("<имя>")` (идемпотентно; так сервер знает, в какой ты комнате).
2. Если нужны преследователи — навмеш: либо `NavigationRegion3D` + `maps/sandbox/nav_bake.gd` (запекание при старте, как в песочнице),
либо заранее запечённый `.res` (как у Backrooms: в вебе без потоков запекать на лету долго).
3. В `export_presets.cfg` добавь пресет-копию «World» (`export_filter="resources"`, `export_files=["res://maps/<имя>/<имя>.tscn"]`,
`exclude_filter="player/*, objects/*, chasers/chaser.gd"`, `export_path="../grid-world/<имя>.pck"`),
а в пресет «Web» — `maps/<имя>/*` в `exclude_filter`. Добавь вызов в `tools/export_all.sh`.
4. В `PORTALS` (`maps/hub/hub.gd`) допиши запись `{title, map, scene, pack, color}` — портал появится сам.
## Карта Race (гонка)
Файлы: `maps/race/` — `race.tscn`, `race.gd` (внешний вид, ворота, лазеры, срыв, дождь/гул, **музыка**), `race_rules.gd` (правила, HUD, сеть), `race_data.gd`
(генерируется из `Blender models/race_01/`, не править), `race.glb`, `sky.png`, `baked.gdshader`, `audio/music.ogg`.
Пакет `race.pck` (в нём `include_filter`: sky.png, baked.gdshader, race_data.gd, race_rules.gd, race.gd, audio/*; без него файлы, которые скрипт грузит по строке, не попадают в пакет).
**Правила.** ОЖИДАНИЕ → ОТСЧЁТ → ГОНКА → ИТОГИ → ОЖИДАНИЕ.
- Ожидание: бегаем свободно. **R** (действие `ready`) переключает «готов»; подсказка «R — готов (n/m)». Все готовы (хотя бы один) → отсчёт.
- Отсчёт 6 с (секунда «приготовьтесь», затем 5…1): все телепортируются в слоты (номер = индекс в отсортированном списке id комнаты; больше 8 — рядами позади, шаг 3 м) лицом по трассе и **заморожены** (`player.input_frozen`; обзор мышью работает).
- Гонка: ворота только по порядку (пересечение плоскости ворот по ходу трассы в пределах полуширины+1 м; чужие ворота игнорируются). Последние ворота = финиш: фиксируется время, дальше бежим свободно. Сплит: «ВОРОТА n время ±разница с личным лучшим забегом».
- Место: финишировавшие — по времени, остальные — по числу ворот, затем по расстоянию до следующих ворот. Конец круга: финишировали все присутствующие **или** прошло 45 с после первого финиша; игрок вышел — убирается из расчётов (если остался один — бежит до финиша). Итоги ~8 с, затем ожидание; готовность сброшена, игроки стоят где стояли.
- Зашедший в разгар круга бегает без зачёта, видит «ГОНКА ИДЁТ» (определяется по маякам других игроков) и участвует со следующего круга.
- **Сдача** (G, удержание 1,5 с): индикатор «СДАЮСЬ…» с полоской внизу, отпустил раньше — отмена. Только в фазе «гонка», у не финишировавшего. Сдавшийся возвращается на стартовую площадку (свободно бегает до конца круга), в итогах стоит ниже всех со строкой «сдался», **время не пишется** ни в итоги, ни в рейтинг. Сдавшиеся считаются завершившими для конца круга (все финишировали/сдались — итоги сразу; иначе 45 с после первого финиша), места остальных считаются без них.
- Срыв: ниже **последних пройденных ворот** на 32 м (до первых ворот — ниже стартового слота) или лазер → «СОРВАЛСЯ!», возрождение на последних воротах лицом по трассе (`Basis(UP, yaw)`; **знак проверен**: `rotation.y = yaw_deg` из `race_data.gd` даёт нужное направление: yaw -90 = на +X). Личный рекорд и сплиты лучшего забега — `user://race_best.cfg` (ConfigFile).
- **Музыка** `audio/music.ogg` (Ogg Vorbis, моно 32 кГц, 0,6 МБ; `ffmpeg -i исходник.mp3 -vn -map_metadata -1 -ac 1 -ar 32000 -c:a libvorbis -q:a 0 music.ogg`) включается в момент «СТАРТ!» с начала, играет до возврата в ожидание и плавно (0,8 с) затихает; дождь и гул остаются под ней. Зашедший в разгар круга слышит её, пока идёт чужой круг.
**Кто что считает (сервер ничего не знает про гонку).** Каждый клиент ведёт свою копию состояния (`race_rules.gd`) и обменивается событиями `Net.RACE_*` через `submit_race(kind, a, b)` / `recv_race(id, kind, a, b)` (+ сигнал `Net.race_event`; сервер просто пересылает остальным в комнате):
| kind | a | b | Кто шлёт |
|---|---|---|---|
| `RACE_READY` | 1/0 | — | игрок при нажатии R |
| `RACE_START` | — | unix-время старта (отправитель: `now + 6`) | только **наименьший id** в комнате, когда видит, что готовы все (поэтому двое «последних готовых» дают один старт; второй START от большего id игнорируется) |
| `RACE_CP` | индекс ворот | время гонки | игрок при прохождении ворот |
| `RACE_FINISH` | — | время финиша | игрок на последних воротах |
| `RACE_STATE` | биты: 0..7 пройдено ворот, 8 готов, 9..10 фаза, **11 сдался** | время финиша или -1 | «маяк» раз в секунду и при смене фазы; по нему догоняют потерявшие событие и зашедшие позже |
| `RACE_GIVEUP` | — | время гонки на момент сдачи | игрок, сдавшийся (G) |
Время: на получении START остаток считается как `b - unix_now`; если он отличается от 6 с больше чем на 0,4 с (часы машин расходятся), считаем 5,9 с от получения. Время гонки у каждого = `(ticks - start_tick)`, финиш `b` пересылается точным числом, поэтому таблицы мест у всех совпадают. «Готов» учитывается только в ожидании; на START все флаги готовности сбрасываются. Ник — `Net.nick_of(id)` (как над призраком), цвет — `Color.from_hsv(id*0.618034…)`. В итогах справа — общий рейтинг карты (`LbView`), на финише клиент шлёт `Net.submit_my_record(rating_id, время)` (для Metro: `map.rating_id = "metro"`).
**Проверка по существу** (всё в `tools/test/`):
- `race_game_test.sh [папка]` — локальный сервер + 2 клиента-бота (A, B) на карте race два круга; третий (C) заходит в разгар круга 1, участвует во втором и выходит посреди него. Проверяет: одинаковые таблицы мест на всех, один START на круг, слоты, синхронность старта, заморозку на отсчёте, музыку после старта, возрождение лицом по трассе (смерть и срыв), зрителя. Сверяет `race_game_check.py`; боты — `race_bot.gd` (автозагрузка во временной копии проекта) по маршруту `race_route.json`. Порт 9080 должен быть свободен; рекорды ботов пишутся в папку теста (`--race-best=`), настоящий не трогается.
- `race_giveup_test.py` — сдача: A отменяет удержание и сдаётся, B финиширует; итоги одинаковы, в рейтинге только B; во втором круге сдались оба — круг сразу закончился.
- `race_tour.tscn` — кадры для проверки HUD/ворот (`--mode=gates|hud`, см. заголовок).
- Пакет: `godot --headless --export-pack Race /tmp/x/race.pck`, затем `strings /tmp/x/race.pck | grep -c` для `sky.png`, `baked.gdshader`, `race_data`, `race_rules.gd`, `music.ogg`, `race.glb` (каждое > 0).
- Пакетная загрузка через портал хаба: `tools/test/race_check.txt` (по образцу `parkour_check.txt`).
**Выкладка (общее правило):** старый клиент с новым сервером (и наоборот) **не работает вообще** — Godot сверяет контрольную сумму набора RPC узла `/root/Net` («rpc node checksum failed»), проверено; поэтому порядок строго по README («Деплой»). `net/net.gd` менялся при Race (`submit_race`/`recv_race`) и при никах/рейтинге (см. «Ники и рейтинг») → после пуша `grid-world` сразу `tools/deploy_server.sh root@192.168.10.110`.
## Ники и рейтинг
**Профиль** — `user://profile.cfg` (ConfigFile, секция `profile`): `client_id` (случайный 128-битный hex, 32 символа; создаётся при первом запуске, **не показывается**, опознаёт игрока в рейтинге; потерял файл/очистил данные сайта — стал новым игроком), `nick`, `asked` (уже спрашивали ник). В тестах путь задаётся `-- --profile=путь`.
**Ники.** При первом запуске в хабе — панель «Введите ник» (≤ 16 символов, Enter — подтвердить, Esc — пропустить: тогда «Игрок N» и больше не спрашиваем). Клавиша **N** (физическая, действие `nick`) в любой момент открывает панель смены с текущим ником; вставка и кириллица работают. Нормализация (`Net.normalize_nick`, один код на клиенте и **сервере**): `\n \r \t` -> пробел, прочие управляющие и невидимые символы (в т.ч. направляющие юникода, нулевой пробел) выбрасываются, пробелы по краям обрезаются, ≤ 16 символов, пустой -> «Игрок N» (N = `id % 1000`). Одинаковые ники в комнате допустимы. Чужой ник везде выводится обычным `Label`/`Label3D` (не RichText) — «[b]» оформление не ломает. Единая точка: `Net.nick_of(peer_id)` (свой — `Net.my_nick`), сигнал `Net.nick_changed(id)`. Где виден: подпись над призраком (`ghost.gd`), HUD и итоги гонки, таблицы рейтинга.
- Сеть: после подключения клиент шлёт `submit_profile(client_id, nick)` **раньше** `submit_map`; при смене ника — снова. Сервер хранит `peer_client` (peer -> client_id) и `client_nick`; при входе в комнату (`submit_map`) присылает вошедшему `recv_nick` всех присутствующих, а им — его; смену ника рассылает комнате; на выходе чистит. Клиент принимает `recv_nick` только при `map_ready` и очищает `Net.nicks` в `_reset_room_state()` — ники не просачиваются между комнатами (как состояние игроков).
- **Блокировка ввода** на время набора: `Net.input_blocked` (ставит `Overlay`). Игрок читает ввод опросом, поэтому `player.gd` при `input_blocked` стоит как при заморозке и не ловит `crouch/dash`, а клик мыши не захватывает курсор. Это **отдельно от `player.input_frozen`** (его ведёт гонка на отсчёте; закрытие панели его не сбрасывает). R/N/Tab/G в поле — обычные буквы/не действия; открывающая клавиша в поле не попадает (фокус берётся отложенно). После закрытия мышь снова захвачена.
**Рейтинг (сервер).** Карты «на время» и допустимые времена — константа `Net.TIMED_MAPS` в `net/net.gd`:
```gdscript
const TIMED_MAPS := {"race": {"min": 25.0}, "parkour": {"min": 40.0}, "metro": {"min": 25.0}}
const MAX_RECORD_TIME := 3600.0
```
Минимумы — **страховка от мусора, не античит**: время быстрее `min` или больше 3600 с сервер молча отбрасывает. Подкрутить — поправить числа (и выложить сервер вместе с клиентом, см. «Деплой»; клиент использует тот же список, чтобы знать, где рисовать доски). Новая карта на время (Metro) — одна строка в `TIMED_MAPS` + `rating_id` в правилах + запись в `PORTALS` хаба: сервер принимает любой id из этого списка.
- `submit_record(map_id, time)`: клиент сообщает свой результат (сервер берёт `client_id` и ник по peer). Проверки: карта из списка **и** игрок сейчас в её комнате, время конечное и в пределах, есть профиль, лимит **6 записей в минуту** на игрока (лишнее молча отбрасывается). Хранится лучшее время каждого `client_id` (худшее не затирает; ник обновляется при смене ника и/или улучшении), не более **100** записей на карту (худшие вытесняются). Ответ: `recv_record_result(map_id, time, rank, personal, map_record)` (место, «новый личный рекорд», «новый рекорд карты» = личный и 1-е место) и `recv_leaderboard`.
- `request_leaderboard(map_id)` -> `recv_leaderboard(map_id, top, my_rank, my_time)`: `top` — до 10 записей `[ник, время, unix-ts]` по возрастанию (при равенстве — кто раньше), `my_rank` — место запросившего (0 — нет записи), `my_time` — его лучшее. Лимит **12 запросов за 10 с** на игрока. Клиент хранит ответы в `Net.boards[map_id]`, сигнал `Net.leaderboard_updated(map_id)`.
- **Хранилище:** `user://leaderboard.json` на сервере. На боевом сервере `user://` = `/var/lib/my-world/.local/share/godot/app_userdata/world2-server/` (служба `my-world`: `StateDirectory=my-world`, `HOME=/var/lib/my-world`, DynamicUser) — папка постоянная, переживает перезапуск службы и `tools/deploy_server.sh` (тот копирует только `net/` и `project.godot`). Формат:
```json
{"version": 1, "maps": {"race": {"<client_id>": {"nick": "Алиса", "time": 52.318, "ts": 1793250000}}, "parkour": {}, "metro": {}}}
```
Запись атомарная (во временный `.tmp`, затем переименование), не чаще раза в 2 с (`SAVE_EVERY`; первая после паузы — сразу). Чтение при старте; отсутствующий файл — пустой рейтинг; испорченный — сервер не падает, файл переименовывается в `leaderboard.json.bad`, рейтинг начинается заново; при загрузке отбрасываются неизвестные карты, неверные id и времена, лишнее сверх 100 на карту. **`systemctl stop/restart` не сбрасывает отложенную запись** (Godot по SIGTERM не присылает уведомление о закрытии): в худшем случае теряются результаты последних ≤ 2 с.
- **Бэкап / сброс:** `ssh root@192.168.10.110 'cp /var/lib/my-world/.local/share/godot/app_userdata/world2-server/leaderboard.json /root/leaderboard-$(date +%F).json'`; сброс — остановить службу (`systemctl stop my-world`), удалить файл, запустить. Проверить, что файл пишется в постоянную папку: после любого результата `ssh root@192.168.10.110 'ls -la /var/lib/my-world/.local/share/godot/app_userdata/world2-server/; journalctl -u my-world -n 5 --no-pager'` (в логе строка `[рекорд] race <ник> <время> (место N)`). Если путь не тот — `find /var/lib/my-world -name leaderboard.json`.
**Таблица лидеров (Tab, действие `leaderboard`).** `core/overlay.gd` + `core/lb_view.gd`: справа по центру, тёмная полупрозрачная подложка, место / ник / `мм:сс.мс`, свой ряд подсвечен, вне топ-10 — строка «Вы: N-й, время»; сверху остаётся таймер гонки, в центре — отсчёт. Игрок при показе не блокируется. Закрывается повторным Tab или Esc.
**Доски в хабе** (`hub.gd`, `_build_boards`): над порталами карт из `TIMED_MAPS` — Label3D на тёмной подложке с топ-3; запрос при входе в хаб и раз в 60 с; нет ответа — «—». У песочницы и Backrooms досок нет.
**Parkour: забег.** Таймер стартует при пересечении **линии на конце стартовой платформы** — Blender `y = 14` (`slab('start', -12, 12, -14, 14, …)` в `layout.py`), в Godot `z = -14`: константа `START_LINE_Z` в `maps/parkour/parkour.gd` (меняете раскладку — поправьте её). Финиш — **вершина**: Area3D 14×5×14 м над верхом платформы `summit`, центр `Data.PORTAL + (0, 2.5, 3)` (в `parkour_data.gd` PORTAL = центр summit + 3 м по Blender-y; `FINISH_SIZE`). Смерть и возрождение таймер не останавливают. На финише — `submit_record("parkour", время)` и панель итогов ~8 с (время, рейтинг, «НОВЫЙ РЕКОРД»). R — телепорт на старт, таймер и чекпоинты сброшены. Таймер маленький, сверху по центру.
**Проверка по существу** (всё в `tools/test/`, порт 9080 свободен):
- `nicks_lb_test.py [папка]` — локальный сервер (его `user://` в папке теста через `XDG_DATA_HOME`) и клиенты `lb_client.tscn`: ники у двоих, у позже зашедшего, смена, изоляция комнат, «[b]»; рейтинг: лучшее время без дублей, сортировка, топ-10 при 14 игроках, неверные записи, лимиты, повторный вход, **перезапуск сервера**, испорченный файл, обрезка 120 -> 100, подписи над призраками в настоящей сцене хаба; в конце `nick_ui_test.tscn` (панель ника, блокировка ввода, R/N/Tab/G в поле, Esc, Tab).
- `parkour_run_test.py` — забег: линия, таймер, смерть, финиш, отказ слишком быстрому времени, R, запись в рейтинге.
- `ui_tour.tscn --mode=hub|race|parkour` — кадры (`--write-movie`; рейтинг подставлен, сервер не нужен; добавляйте `--server-url=ws://127.0.0.1:1`, иначе окно подключится к реальному серверу и затрёт подставленное).
## Преследователи
`chasers/chaser.gd` — один скрипт для всех. Сцены (`rock.tscn`, `verity.tscn`) — это просто модель + звук + `NavigationAgent3D`.
- **Поведение:** гонится за ближайшим живым игроком по навмешу, ловит на `catch_distance` (по горизонтали, не выше `catch_height`).
После поимки игрок умирает и возрождается через 2,5 с в стартовой точке; если преследователь рядом со стартом — уходит на свой старт.
- **Зрение** (`sight_limited = true`): замечает игрока, если он ближе `hearing_radius` (слышит даже за стеной) **или** в конусе `sight_fov`
до `sight_range` и луч до него не упирается в стену. Потерял из виду — идёт к последнему месту `lose_time` секунд, потом блуждает (`patrol_speed`, `wander_radius`).
Без `sight_limited` знает, где игрок, из любой точки.
- **Параметры** (экспорты узла): `chaser_id` (имя в сети), `speed`, `turn_speed`, `catch_distance`, `catch_height`, `sight_*`, `hearing_radius`,
`lose_time`, `patrol_speed`, `wander_radius`, `model_scale` (масштаб дочернего узла `Model`), `emission_energy` (чисто светящийся материал; у Верити 1,0).
- **Значения сейчас:** камень в песочнице — скорость 15, зрение 45 м / 130°, слух 8 м, лимит потери 5 с. Верити в песочнице — скорость 12, зрение 55 м / 110°, слух 10 м, потеря 6 с.
Верити в Backrooms — скорость 10, масштаб 0,5, зрение по умолчанию (30 м / 120° / слух 6 м / потеря 4 с).
- **Модели — плоские «карточки»** вдоль оси X; в сцене повёрнуты на 90° и подняты, чтобы стоять на полу. У `verity2` лицо нарисовано с одной стороны
(свечением), при погоне оно повёрнуто к игроку.
- **Добавить преследователя:** новая сцена по образцу `verity.tscn` (корень со скриптом `chaser.gd`, `chaser_id="имя"`, `NavigationAgent3D`),
инстанс в карту. Больше ничего: сеть синхронизирует всех по имени.
## Сеть
**Архитектура:** выделенный сервер на Godot (WebSocket, порт **9080**), клиенты — `WebSocketMultiplayerPeer`. Сервер **не знает про уровни**
и ничего не считает, только пересылает сообщения внутри «комнаты» (карты). Физика, управление, **преследователи** считаются на клиентах.
- `net/net.gd` — автолоад `Net`. `DisplayServer.get_name() == "headless"` → **сервер**, иначе клиент; флаг `--client` делает клиентом и headless (боты).
Клиент переподключается каждые 3 с. Адрес в вебе берётся из `window.location` (`wss://<домен>/ws` или `ws://`), на десктопе — `ws://127.0.0.1:9080` или `-- --server-url=...`.
- **Комнаты по картам.** Клиент при входе на карту шлёт `submit_map(имя)`, сервер отвечает `recv_map_ack`. Пока подтверждения нет (`Net.map_ready == false`),
клиент игнорирует чужие сообщения — так при смене карты не просачиваются старые пакеты. Игроки разных карт друг друга не видят.
- **Владелец преследователей.** В каждой комнате один клиент (первый вошедший, затем наименьший `peer_id` из оставшихся) симулирует **всех** преследователей и
шлёт их позиции 20 раз/с; остальные клиенты плавно повторяют. Если владелец молчит `ROCK_TIMEOUT` = 3 с (вкладка в фоне, лаги) или вышел — сервер передаёт роль другому;
преследователь продолжает с того же места. Поимку «своего» игрока всегда решает его собственный клиент по тому, что он видит.
- **Смерть и возрождение** шлются отдельно (`submit_life`), чтобы владелец не гнался за мёртвыми и мог увести преследователя от точки возрождения.
| RPC | Кто → кому | Что |
|---|---|---|
| `submit_map(map)` / `recv_map_ack(map)` | клиент → сервер / обратно | вход в комнату |
| `recv_rock_owner(id)` | сервер → клиенты | кто симулирует преследователей |
| `submit_state(pos, yaw, pitch, flags)` / `recv_state(id, …)` | клиент → сервер → остальные | состояние игрока, 20 Гц, `unreliable_ordered` |
| `submit_chasers(ids, positions, yaws)` / `recv_chasers(…)` | владелец → сервер → остальные | позиции всех преследователей, 20 Гц |
| `submit_life(dead, pos)` / `recv_life(id, dead, pos)` | клиент → сервер → остальные | смерть/возрождение, `reliable` |
| `recv_left(id)` | сервер → комната | игрок вышел |
| `submit_profile(client_id, nick)` / `recv_nick(id, nick)` | клиент → сервер / сервер → комната | профиль и ники (раздел «Ники и рейтинг») |
| `submit_record(map_id, time)` / `recv_record_result(map_id, time, rank, personal, map_record)` / `recv_leaderboard(map_id, top, my_rank, my_time)` | клиент → сервер → клиенту | рекорды |
| `request_leaderboard(map_id)` | клиент → сервер | запрос таблицы (ответ — `recv_leaderboard`) |
Флаги состояния (`Net.FLAG_*`): подкат, бег по стене (+ сторона стены), рывок, подтягивание, «в воздухе». Присед передаётся как подкат; призрак отличает их по скорости. Скорость и двойной прыжок призрак считает сам по смещению позиции — в протокол они не входят.
**RPC требуют одинаковые путь узла и скрипт на сервере и клиенте**, поэтому `net.gd` один. Любое изменение RPC — **клиент и сервер выкладываются вместе**
(старый клиент с новым сервером не работает). Имена вроде `rock_owner`/`recv_rock_owner` остались от времени, когда преследователь был один.
## Загрузка карт пакетами (веб)
Основной пакет игры (`index.pck`) — только хаб, игрок, сеть и порталы (около 70 КБ). Движок — `index.wasm` (39,5 МБ, ~10 МБ с gzip).
Карты — отдельные `.pck`: `world.pck` (13,6 МБ), `backrooms.pck` (21,3 МБ). Портал вызывает `MapLoader.enter(map, scene, pack, title)`:
1. если сцена уже есть в проекте (редактор, десктоп, пакет уже скачан) — переход сразу;
2. иначе игра встаёт на паузу (преследователь не должен убить игрока на экране загрузки), показывается «Загрузка: <имя> N / M МБ»;
3. пакет качается `HTTPRequest` с `Cache-Control: no-cache` (браузер сверяет файл с сервером), записывается в `user://` и подключается через
`ProjectSettings.load_resource_pack(path, false)` (**`false`** — файлы основной игры в приоритете, устаревший пакет не затрёт код);
4. при ошибке на экране и в консоли пишется причина, портал можно повторить.
**Грабли, на которые уже наступили:**
- `HTTPRequest` в браузере: **`accept_gzip = false`** обязательно. Браузер сам распаковывает gzip, а движок видит `Content-Encoding: gzip` и пытается распаковать ещё раз (ошибка 8).
- **`download_file` в вебе не создаёт файл** — тело берём в память и пишем в `user://` сами.
- Файлы-«не ресурсы» (`.mp4`, `.md`) не экспортируются; потому исходное видео Верити лежит вне проекта (`source-assets`).
- Предупреждения `invalid UID … using text path instead` при загрузке пакета безвредны.
- **Ресурсы, которые скрипт грузит строкой** (`load("res://…")`), в пакет карты **не попадают**: пресет `export_filter=resources` берёт только сцену и её прямые зависимости (`preload` тоже находится). Добавляй такие файлы в `include_filter` пресета карты (у Parkour: `sky.png, baked.gdshader, parkour_data.gd, audio/*`). Признак беды: пакет «слишком маленький». Проверка: `strings parkour.pck | grep -c sky.png` (должно быть > 0). Раз это случилось: карта тихо грузится без неба, шейдера и логики, а проверка «сцена называется Parkour» этого не замечает — **проверяй по существу** (см. «Тесты»).
- **Кэш зависимостей редактора** у недавно созданных/изменённых сцен может устареть, и экспорт пакета карты пропустит модели и звуки (пакет «слишком маленький»).
`tools/export_all.sh` делает `touch` сцен перед экспортом. Признак беды: размер пакета не вырос после добавления ресурсов.
## Экспорт, тесты и деплой
### Экспорт
```bash
tools/export_all.sh # веб-клиент + backrooms.pck + world.pck в ../grid-world
```
Три пресета в `export_presets.cfg`: **Web** (хаб + общее; из него исключены `maps/sandbox`, `maps/backrooms`, `chasers/rock`, `chasers/verity`, `tools`),
**World** и **Backrooms** (`export_filter=resources` — сцена карты и всё, на что она ссылается, кроме общего из основного пакета). Шаблоны экспорта —
`~/.local/share/godot/export_templates/4.7.2.stable/` (есть только Web). Экспорт при выходе печатает безвредную ошибку `Parameter "singleton" is null`.
### Проверка сборки как на сервере
```bash
python3 tools/serve_web.py ../grid-world 8080 # nginx-подобно: COOP/COEP и gzip чанками
```
Открыть `http://127.0.0.1:8080`. На десктопном `godot --main-pack` пакеты карт качаются плоским `python3 -m http.server` (без gzip) через `-- --pack-url=http://127.0.0.1:8777/`.
### Тесты без окна (боты)
Автотестов в CI нет; для проверок есть боты. `tools/test/make_test_project.sh /tmp/tp` делает временную копию проекта с автозагрузкой `tools/test/bot.gd`
(телепортирует игрока по расписанию и печатает состояние раз в секунду), `tools/test/make_test_server.sh /tmp/ts` — локальный сервер в боевой раскладке.
```bash
godot --headless --path /tmp/ts & # сервер
godot --headless --path /tmp/tp res://maps/sandbox/sandbox.tscn -- --client --name=A &
godot --headless --path /tmp/tp res://maps/sandbox/sandbox.tscn -- --client --name=B
# цепочка карт на реальных пакетах (портал SANDBOX в хабе на (-2.25, -7.5), BACKROOMS на (2.25, -7.5)):
/tmp/tp/tools/export_all.sh /tmp/site && (cd /tmp/site && python3 -m http.server 8777 --bind 127.0.0.1 &)
godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:-2.25,1,-7.5
```
Для картинок: `godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/a.png --quit-after 25 res://maps/hub/hub.tscn` (окно мелькнёт; кадры — `a00000020.png`).
В браузере: Chrome (flatpak `com.google.Chrome`) `--headless=new --enable-logging=stderr --v=0 http://127.0.0.1:8080` — вывод Godot виден в строках `CONSOLE`.
**Готовые проверки** (`tools/test/`, запускаются как сцены: в режиме `--script` автозагрузки недоступны при компиляции, поэтому только `res://…tscn`; `-- --client`, иначе headless поднимет сервер на порту 9080):
- `measure_movement.tscn` — замеры мувсета (прыжки, рывок, стена); `mantle_test.tscn` — подтягивание на 40 вариантах уступов; `parkour_gaps.tscn` — проходимость контейнеров; `parkour_tour.tscn` — облёт карты со снимками (см. заголовок файла).
- **Проверяй по существу, а не по имени сцены.** Для карт из пакетов: собери тестовый проект (`make_test_project.sh`), в его `bot.gd` добавь печать фактов (есть ли у `current_scene` скрипт, подменён ли материал, есть ли `PanoramaSkyMaterial`, играет ли музыка, сколько лазеров) и пройди портал с `--pack-url`. Готовый код проверки — `tools/test/parkour_check.txt` (ловит пропавшие `sky.png`/шейдер/музыку/логику).
- Ввод ботом: `Input.action_press("jump")` работает для зажатия и `is_action_pressed`; рывок и подкат ловятся в `_unhandled_input`, поэтому для них нужно `Input.parse_input_event(InputEventAction)` с `pressed=true`.
- Грабли оболочки: в этом окружении `ls` — алиас на `eza` (используй `command ls`), `cp` спрашивает подтверждение (`command cp -f`), zsh не режет `$args` по пробелам (передавай аргументы отдельно), `echo ======` ломает команду.
**Осторожно:** `pkill -f`/`pgrep -f` с шаблоном, который есть и в твоей же командной строке, убивает собственный шелл; не используй `pkill chrome` — закроет браузер владельца. Останавливай по PID или по точному признаку.
### Деплой
```
Браузер ──https/wss──▶ Nginx Proxy Manager (SSL, домен на duckdns) ──http──▶ контейнер 192.168.10.110
nginx :80
├─ / → /opt/grid-world (статика игры)
└─ /ws → 127.0.0.1:9080 (игровой сервер)
```
**Сервер:** `root@192.168.10.110`, Debian 12, контейнер LXC в Proxmox, 1 ядро, 1 ГБ RAM, ssh по ключам. Нет `curl`, `rsync`, `ufw`; есть `python3`.
В Nginx Proxy Manager включено **Websockets Support** (без него `/ws` не работает). Боевой конфиг nginx: `deploy/nginx.conf`
(`/etc/nginx/sites-enabled/godot_game`); обязателен **gzip для `.wasm`/`.pck`** (иначе 39,5 + 21 МБ уходят как есть) и заголовки COOP/COEP.
**Клиент:** `tools/export_all.sh`, затем коммит и пуш в `grid-world` (`main`). Gitea (`git.ibrash.duckdns.org`, `aibrash/grid-world`) по пушу запускает
`.gitea/workflows/deploy.yml`: ssh на сервер и `git pull` в `/opt/grid-world`. Пакеты карт выкладываются вместе с `index.*`.
**Сервер:** отдельный минимальный проект в `/opt/my-world-server/` (`project.godot` + `net/` + официальный бинарник Godot 4.7.2 linux x86_64 `godot`; бинарник из Fedora на Debian не подходит,
берётся из `github.com/godotengine/godot-builds`). Служба systemd `my-world.service` (`deploy/my-world.service`, DynamicUser, `Restart=always`).
```bash
tools/deploy_server.sh root@192.168.10.110 # бэкап, копирование net/ и project.godot, пересборка кэша классов, перезапуск
ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок …», «[карта] камень считает игрок …»
```
Трогать сервер нужно только при изменении `net/*`. **Порядок при изменении RPC:** сначала пуш `grid-world`, сразу следом `deploy_server.sh`; игрокам — Ctrl+F5.
Откат сервера: бэкап в `/opt/backup-my-world-server-<дата>/`.
> Сейчас на сервере ещё **старая раскладка** (`/opt/my-world-server/Net/`, заглавная). `deploy_server.sh` при следующем запуске переведёт её на `net/`.
> Код сети при этом по протоколу не изменился.
### Git
- Репо проекта: `git@git.ibrash.duckdns.org:aibrash/my-world.git`, основная ветка `master`; коммиты на русском с трейлером `Co-Authored-By`.
- **Пушить и выкладывать на сайт — только по прямой просьбе владельца.** Коммитить локально можно.
## Что выяснено по ходу работы
- **Окружение:** в рендерере Compatibility **объёмный туман не работает** (удалён из песочницы). SSAO и glow работают (неоновое свечение), оставлены.
В Backrooms включён обычный туман (`Environment.fog_*`), он работает.
- **Память:** раньше вкладка показывала около 1,6 ГБ; в окт 2026 владелец замерил **~300 МБ** (после замены капсул на модель со скелетом, новых карт и всех правок этой сессии). Что именно помогло — неизвестно (гипотезы прежних замеров: несжатые текстуры, HDR-небо, `.ogg` целиком в памяти). Проблему считаем снятой; повторно измерять при добавлении тяжёлого контента (небо Parkour/Race ~6 МБ в видеопамяти каждое).
Гипотезы: рост WASM-памяти, несжатые текстуры, HDR-небо 2k, `.ogg`, распаковываемый целиком. Измерения с тех пор не повторялись.
- **Материал Верити** — две поверхности: жёлтая основа и диск с лицом (текстура свечения на белой основе). Белая основа на ярком свету даёт бледность,
поэтому `emission_energy` делает материал чисто светящимся (чёрная основа).
- **Backrooms:** двери ~1,8 м, поэтому преследователь там масштабом 0,5 и навмеш запечён для агента радиусом 0,6 (иначе проход закрыт). Самый дальний зал за узкой дверью недостижим.
- **Ввод:** `Input.is_key_pressed(KEY_W)` проверяет символ, на русской раскладке это «Ц». Только действия `InputMap` на физические клавиши.
- **Пресеты:** при отключённом `extensions_support` — один `index.wasm`; включение даёт ещё `index.side.wasm` 44 МБ.
## Заметки для будущего меня
**Договорённости с владельцем (важно не нарушать)**
- Коммитить локально можно, **пушить и выкладывать — только по прямой просьбе**. Сервер выкладывать нужно лишь при правках `net/*` с изменением RPC.
- Parkour — **мрачная антиутопия** (дождь, грязный смог, мокрый бетон, натриевый оранжевый, красный, больной зелёный; надписи-приказы COMPLY/CURFEW). Яркий розово-бирюзовый неон владелец назвал «детским». Туман 0,011 выбран из серии проб (0,02 владелец откатил как слишком густой), цвет тумана светлее фона — тёмный туман лишь затемняет.
- Сложность Parkour владельцу «нормально»; он просил и «не детский уровень» — если захочет жёстче: зазоры 27–28 м (двойной прыжок + рывок), лазеры в каньоне, быстрее пульсы, реже чекпоинты.
- Сальто камеры при двойном прыжке делалось и **убрано по просьбе** (тошнит, не видно куда прыгать). Без просьбы не возвращать.
- Чекпоинты должны быть **заметными** (раньше тонкие кольца на полу не нашли): сейчас ворота 5 м с надписью CHECKPOINT.
- Мой стиль ответов: по-русски, коротко, без перечисления всего подряд; честно говорить, что проверено, а что нет.
**Мувсет (замеры ботом, спринт 13 м/с)** — нужны при любом проектировании карт:
одиночный прыжок 15 м и +1,9 м вверх; двойной 25 м и +3,5 м; прыжок+рывок 19 м; двойной+рывок 28 м; бег по стене 1,8 с (23 м), провал 1,4 м; прыжок от стены ~5 м в сторону. **Стенами высоту не набрать**: прыжков от стены без бега только `MAX_WALL_JUMPS = 2` за полёт, а прыжок с бега по стене почти ничего не даёт — высоту дают двойной прыжок и подтягивание (уступ до ~2,6 м над полом, с двойным прыжком до ~4,5). Шахта-«лифт» из стен невозможна; в Parkour вместо неё башня из уступов.
**Подтягивание** (`player.gd → _try_mantle`): цель — центр капсулы на `default_capsule_height * 0.5 + 0.05` над поверхностью уступа. Раньше стояло `+ 0.05` (центр на уровне поверхности): тело оказывалось наполовину в уступе, физика выталкивала назад, и на уступах от 2 м не работало (на всех картах!). Проверка — `mantle_test.tscn`.
**Конвейер Parkour** (`Blender models/parkour_02/`): `layout.py` (единственный источник раскладки) → `build_parkour.py` (в Blender: геометрия `Solid`/`Neon`/`Collision-colonly`, пишет `parkour_data.gd`) → `bake.py` (`blender -b Parkour_02.blend -P bake.py`: запекание Cycles в цвета вершин + экспорт GLB, ~1 мин) → `build_sky.py` (`blender -b -P build_sky.py`: панорама ~2 мин) → `godot --headless --import --path .`. Правка раскладки требует всех четырёх шагов; по ходу собирай и смотри картинки (снимки `parkour_tour`).
- **Освещение — в цветах вершин**, а не в динамических источниках. Сетка граней режется на ячейки ≤ 1,6 м (`kit.Mesh.grid`), поэтому число треугольников растёт быстро: сейчас Solid ~50 тыс. четырёхугольников, Neon ~22 тыс. Фон и дно — грубая сетка (9–80 м). Не раздувай: веб.
- glTF хранит цвета вершин нормализованными (до 1), поэтому в `bake.py` значения сжаты ×0.125, а шейдер `baked.gdshader` умножает на `light_scale = 8`. Освещённость без этого обрезается на единице.
- Яркость уровня: сила света в `bake.py` (Moon/CityGlow/Smog и цвет мира) × `exposure` в `parkour.gd` (2,1) × цвет материала (`kit.SOLID` и `SURFACES` в `parkour.gd` — **должны совпадать**). Первые попытки были чёрными: средняя запечённая освещённость ~0,05 × альбедо 0,11 → почти ноль.
- Тексты (`kit.Mesh.text`): без поворота смотрит в −Y (читается, если смотреть вдоль +Y); для видимой с +X грани нужен `yaw = +π/2`, с −X — `−π/2`, для видимой со стороны +Y — `π`. Ошибка с ориентацией даёт зеркальные надписи. Кривые букв огрублены (`resolution_u = 2`) — иначе десятки тысяч лишних треугольников.
- Имена в русском интерфейсе Blender переведены: узлы шейдеров/мира искать **по `type`**, не по имени (`'Background'` не найдётся).
- Экспорт цветов вершин в GLB требует активного атрибута (`export_vertex_color='ACTIVE'`); Cycles-запекание в `target='VERTEX_COLORS'`.
- Панорама неба: мипы включены в `sky.png.import` (`mipmaps/generate=true`, `compress/mode=2`) — по ним размытые отражения в мокрых полах (`textureLod` в шейдере). Дно города в панораме — отдельный материал: общий шейдер дымки на огромной плоскости давал белое.
- Не мерялось: нагрузка Parkour в браузере (~145 тыс. треугольников + дождь 2200 частиц, небо ~6 МБ в видеопамяти), звук (шум дождя, гул, песня) на слух.
- Расходный ресурс: `mcp__blender__look` очень дорог по токенам (сотни тысяч за сессию) — снимки просматривай из Godot (`parkour_tour`), в Blender смотри только геометрию.
**Ники и рейтинг — грабли**
- Любой `Input`-опрос не знает о LineEdit: блокировать надо самому (`Net.input_blocked`), `input_frozen` не трогать (гонка). Клавиши, набираемые в LineEdit, до `_unhandled_input` не доходят (поле их съедает), но R/N в правилах всё равно проверяют `Net.input_blocked` — на всякий случай.
- `Input.parse_input_event` в тестах доставляет события по-настоящему (в поле попадают буквы, действия не срабатывают) — так проверена панель ника без окна. Режим мыши в headless не хранится: захват проверять руками.
- Тесты, которым нужен рейтинг, стартуют сервер с `XDG_DATA_HOME=<папка теста>` (иначе пишут в `~/.local/share/godot/app_userdata/world2-server/` разработчика). `pkill -f` с путём теста убивает собственную оболочку — глуши по PID.
- Время записи на сервере сравнивается с `min` из `TIMED_MAPS`: тест Parkour «состаривает» старт на 45 с (`run_start_tick -= 45000`), иначе пришлось бы бежать минуту.
- Хаб стал на 1 м выше (7 м): доски над порталами и «CHOOSE A MAP» над ними иначе перекрывались.
- SIGTERM не сбрасывает отложенную запись рейтинга (≤ 2 с) — см. раздел «Ники и рейтинг».
- Отправка профиля обязана идти **раньше** `submit_map` (оба `reliable`, порядок сохраняется) — иначе вошедший в комнату покажется остальным без ника.
- Правка набора RPC (`submit_profile`, `recv_nick`, `submit_record`, `request_leaderboard`, `recv_leaderboard`, `recv_record_result`) — клиент и сервер выкладываются **вместе**.
**Race — что выяснено**
- Имя `ready` у переменной в `Node` занято сигналом движка (`Node.ready`): флаги готовности названы `flags_ready`.
- Данные ворот — центр капсулы на полу: ворота ставим на `pos.y - 1.2`; слоты на 1,25 м, при заморозке игрок оседает на ~9 см (это нормально).
- Маршрут бота `route.json` проходит ворота ровно по центру; точки маршрута на воротах бот обязан «брать» только после пересечения плоскости (иначе срезает угол, как делал мой первый бот).
- В `--write-movie` с `canvas_items`+`expand` видимая область шире окна, и подписи у левого края обрезаются; HUD слева отступает на 64 px.
- Кадры гонки: `race_tour.tscn --mode=hud` (R, отсчёт, пробег, итоги). Подробности — раздел «Карта Race».
## Дорожная карта
### Телефон — не планируется
Владелец решил, что телефон не нужен (окт 2026). Если когда-нибудь вернёмся: нужны сенсорный стик и зона обзора (ввод уже сведён к действиям `InputMap`, достаточно `Input.action_press`), ориентация и полноэкранный режим по касанию,
экран «Нажми, чтобы начать» (аудио в браузере без жеста не стартует), мобильные текстуры ETC2/ASTC (`vram_texture_compression/for_mobile`; удвоит пакеты — делать отдельными `*.mobile.pck`), отключение SSAO/glow и `scaling_3d_scale` 0,6–0,75,
восстановление после ухода вкладки в фон (`submit_map` заново). Сначала измерить память и FPS на реальном устройстве.
### Ближайшее: текстовый чат (ПЛАН, не реализовано — ждёт команды владельца)
Ники **сделаны** (раздел «Ники и рейтинг»): `Net.nick_of(id)`, `Net.my_nick`, `Net.input_blocked` для панели ввода. Чат строится на них. Смена ника по команде `/ник` не нужна — есть клавиша N.
**Чат**
- Открытие: действие InputMap `chat` на физические клавиши T и Enter (работает на любой раскладке); Enter — отправить и закрыть, Esc — закрыть. Чат — в комнате текущей карты (в хабе/песочнице/Backrooms/Parkour/Race разные).
- UI: CanvasLayer, внизу слева, мрачный минимализм (тёмная полупрозрачная подложка, небольшой шрифт, без ярких цветов), последние ~8 строк, видны ~10 с и гаснут; `Ник: текст`, ник цветом игрока (цвет от id, как у призрака), системные строки серым («зашёл»/«вышел»). **Без BBCode**: чужой текст не должен менять оформление (Label или RichTextLabel с выключенным BBCode). Желательно пузырь с последним сообщением над призраком на ~6 с.
- **Ввод при наборе:** уже есть `Net.input_blocked` и образец — панель ника в `core/overlay.gd` (флаг отдельный от `input_frozen`, мышь освобождается и потом захватывается, открывающая клавиша в поле не попадает, R/N/Tab/G в поле не действия). Повторить для чата.
- Сеть: RPC `submit_chat(text)` → сервер валидирует и пересылает остальным в комнате (`recv_chat(id, text)`), отправитель видит своё сразу. **Серверные ограничения:** ≤ 200 символов, убрать управляющие, пустые отбросить, лимит ~5 сообщений за 5 с на игрока (молча отбрасывать). Команды `/…` обрабатываются на клиенте и не рассылаются.
**Проверка (по существу):** образец — `tools/test/nicks_lb_test.py`: чат только в своей комнате; лимиты длины/частоты; «[b]» не ломает оформление; при открытом чате бот не двигается, после закрытия — двигается; R/T в поле не срабатывают как действия. Регрессия: тест гонки, `mantle_test`, `measure_movement`. Кадры панели ника и чата через `--write-movie`: не перекрывают HUD гонки (сверху таймер, по центру отсчёт), кириллица рисуется.
**Выкладка:** меняется набор RPC → клиент и сервер вместе (порядок из «Деплой»), игрокам Ctrl+F5; старый клиент с новым сервером падает с «rpc node checksum failed».
### Идеи (оценка независимого консультанта, окт 2026; не обязательства)
- **Parkour/Race:** режим «время и призрак» (запись лучшего забега, показ призраком), старт гонки по таймеру при зависшем «готов» (сейчас один не нажавший R блокирует всех), развилки Race с большим выигрышем, спектейт для мёртвых, приватные комнаты по ссылке.
- **Мувсет и ощущение:** анимация приземления, звуки шагов/приземления/подката/бега по стене/рывка (самый заметный эффект при малых затратах), настройки и меню паузы (чувствительность, громкость, FOV).
- **Контент и социальное:** игрок в роли преследователя, голос по близости, подбираемые предметы/двери, гроза со вспышкой молнии на картах, кастомизация персонажа.
- **Инструменты:** номер версии протокола (чтобы старый клиент получал «обнови страницу»), автопроверка пакетов в CI, одна команда «собрать карту», экран credits (CC-BY Backrooms VR).
- Серверные проверки (антиспуфинг позиций), если игроков станет много.
## Персонаж
Модель и анимации **генерирует скрипт** `Blender models/character_01/build_character.py` (Blender 5.x: `exec` в окне или `blender -b -P`).
Он строит меш из гранёных сегментов (жёсткие веса: одна кость на сегмент), арматуру из 17 костей, считает позы и пишет 14 анимаций
(`idle walk run crouch_idle crouch_walk slide jump fall air_jump dash wallrun_R wallrun_L mantle death`), ноги ставятся через IK.
Экспорт: GLB, `export_animation_mode=NLA_TRACKS`, в `player/character/character.glb`. Материалы: `Suit` (тёмный) и `Glow` (свечение; в игре заменяется цветом игрока).
- **Выбор анимации** — `Ghost._choose()`: смерть > подтягивание > рывок > бег по стене > воздух > присед/подкат > бег/ходьба/стоит.
Скорость шага подгоняется `speed_scale` (`REF_SPEED`), между анимациями смешивание 0,15 с. Голова повторяет взгляд вверх-вниз.
- **Своё тело:** та же модель стоит в `player/player.tscn` (видна в редакторе, отбрасывает тень), но спрятана от своей камеры слоем 2 (`player.gd → _ready`). Не анимируется.
- **Выкладка сервера:** `ghost.gd` лежит и на сервере, где модели нет, поэтому модель грузится `load()` внутри `setup()`, а не `preload`.
- Изменить позы: правь функции анимаций в скрипте, перезапусти его в Blender, заново экспортируй GLB и импортируй в Godot.
## Название и заставка
Игра называется **Aman's World** (`config/name`). Заставка загрузки — `branding/splash.png` (1280×720, генерируется скриптом на Pillow: дождь, силуэты домов, натриевое свечение; при смене надписи/стиля перерисуй и положи на то же место), иконка и фавиконка — `branding/icon.svg`. Всё задаётся в `project.godot → [application]` (`boot_splash/image`, `boot_splash/bg_color`, `config/icon`); веб-экспорт берёт из них `index.png`, `index.icon.png` и `<title>`. **Папка пользовательских данных осталась прежней** (`config/use_custom_user_dir`, `custom_user_dir_name="world2"`): профиль (`profile.cfg`) и рекорд (`race_best.cfg`) не должны потеряться при переименовании проекта. Серверный проект (`deploy/server/project.godot`) — отдельный, его имя не менялось.
## Журнал изменений (окт 2026)
Сделано одной серией работ (хронологически):
1. **Персонаж:** модель киберниндзя со скелетом (752 треугольника, 14 анимаций, IK ног) вместо капсул; цвет свечения от id игрока; свой персонаж спрятан от своей камеры. Сальто камеры при двойном прыжке делалось и убрано по просьбе.
2. **Подтягивание на уступы** починено (не работало от ~2 м на всех картах), замеры мувсета и тестовые сцены в `tools/test/`.
3. **Parkour:** подъём по мегабашне (крыши, каньон, башня, лазерный зал, контейнеры, спираль, вершина), запечённый свет, панорама неба, мокрый блеск, дождь, туман, песня; мрачная антиутопия.
4. **Race:** гонка на ногах (трасса 633 м из Blender, три развилки, 8 ворот), правила и HUD, новые сетевые события, песня на «СТАРТ!»; сделано агентами, проверено двухклиентным тестом на локальном сервере.
5. **Инфраструктура:** пресеты экспорта карт (с `include_filter` для файлов, грузимых по строке), проверка пакетов «по существу», выкладка сайта и сервера по порядку из «Деплой».
6. **Замеры:** вкладка в браузере ~300 МБ.
## Лицензии и авторство
- Модель карты Backrooms — «Backrooms VR», автор VTX (https://sketchfab.com/VTX_car), **CC-BY 4.0** (`maps/backrooms/model/CREDITS.md`).
При публикации нужно указывать автора.
- Остальные модели и звуки — материалы владельца проекта.