Ники, общий рейтинг (топ-10 по картам), таблица Tab, сдача в гонке, таймер Parkour

- Ники: профиль user://profile.cfg (client_id + ник), панель при первом запуске и по N;
  Net.nick_of(id); ники над призраками, в HUD/итогах гонки и таблицах
- Рейтинг на сервере: лучшее время каждого игрока по картам race/parkour/metro, топ-10,
  user://leaderboard.json (атомарная запись, лимиты, проверка карты/комнаты/времени)
- Таблица лидеров по Tab, доски топ-3 над порталами в хабе, итоги гонки с общим рейтингом
- Гонка: сдача (G, удержание 1,5 с), RACE_GIVEUP; Parkour: таймер, финиш на вершине, R — перезапуск
- Новые RPC (submit_profile, recv_nick, submit_record, recv_record_result, request_leaderboard,
  recv_leaderboard): клиент и сервер выкладываются вместе
- Тесты: nicks_lb_test, race_giveup_test, parkour_run_test, nick_ui_test, ui_tour; README обновлён

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
This commit is contained in:
2026-10-29 12:09:33 +05:00
co-authored by Claude Sonnet 5.5
parent d38152ea97
commit 666bebfb60
31 changed files with 2014 additions and 44 deletions
+68 -19
View File
@@ -26,7 +26,8 @@
| ✅ | Управление **не зависит от раскладки** (русская и любая другая): действия `InputMap` на физические клавиши |
| ✅ | Деплой: CI из `grid-world` на сайт; скрипты экспорта, dev-сервера, тестов без окна, выкладки сервера |
| ❌ | Управление с помощью геймпада |
| ❌ | Ники и текстовый чат — **запланированы**, не сделаны (подробный план в «Дорожная карта → Ближайшее») |
| ✅ | **Ники** (панель по N, подпись над игроками, в итогах гонки) и **общий рейтинг** лучших времён на сервере (топ-10 на Race/Parkour, таблица по Tab, доски в хабе, таймер и итоги Parkour, сдача в гонке на G) — раздел «Ники и рейтинг» |
| ❌ | Текстовый чат — **запланирован**, не сделан (план в «Дорожная карта → Ближайшее») |
| ❌ | Настроек (чувствительность мыши, громкость), меню паузы |
| ❌ | Серверных проверок (антиспуфинг позиций, античит): сервер только пересылает |
| ❌ | Сохранения прогресса, аккаунтов |
@@ -50,7 +51,7 @@ godot --path . # можно запустить несколько ок
## Управление
Клавиши привязаны к **физическому расположению** (так работает и на русской раскладке). Действия лежат в
`project.godot → [input]`: `move_forward/back/left/right`, `jump`, `sprint`, `crouch`, `dash`, `ready` (R — «готов» в гонке).
`project.godot → [input]`: `move_forward/back/left/right`, `jump`, `sprint`, `crouch`, `dash`, `ready` (R — «готов» в гонке, перезапуск забега в Parkour), `nick` (N), `leaderboard` (Tab), `giveup` (G).
| Клавиша | Действие |
|---|---|
@@ -60,7 +61,10 @@ godot --path . # можно запустить несколько ок
| C (держать) | **присед** (скорость 3,5, капсула вдвое ниже, пролезает под низкое) |
| Shift + C на бегу | **подкат** |
| Q | рывок |
| R | «готов» (только на карте Race) |
| R | «готов» (карта Race); на карте Parkour — **забег заново** (на старт, таймер сброшен) |
| G (держать 1,5 с) | **сдаться** в гонке (только пока идёт гонка; отпустил раньше — отмена) |
| N | ввод/смена ника (панель по центру; Enter — подтвердить, Esc — пропустить/отмена); на время ввода игрок стоит |
| Tab | таблица лидеров текущей карты (топ-10 и «Вы: N-й»); в хабе и на картах без рейтинга — вкладки по картам, A/D или стрелки; повторный Tab или Esc закрывают |
| ЛКМ (зажать) | зум |
| Esc / клик | отпустить / захватить мышь |
@@ -71,12 +75,14 @@ godot --path . # можно запустить несколько ок
```
project.godot, export_presets.cfg, icon.svg
net/ сеть. Net — автолоад: и клиент, и сервер (один скрипт)
net.gd состояние комнат, преследователей, RPC (см. «Сеть»)
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/
@@ -113,10 +119,10 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
| Карта | Сцена | Пакет | Что там |
|---|---|---|---|
| Хаб | `maps/hub/hub.tscn` | в основном пакете | Комната 28×18 м, строится кодом (пол-грид, стены, свет). Порталы SANDBOX и BACKROOMS в ряд по центру, влезает 6. Безопасна, преследователей нет |
| Хаб | `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). Подъём по мегабашне, ~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`), играет по кругу |
| 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/`):
@@ -151,6 +157,7 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
- Гонка: ворота только по порядку (пересечение плоскости ворот по ходу трассы в пределах полуширины+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 с) затихает; дождь и гул остаются под ней. Зашедший в разгар круга слышит её, пока идёт чужой круг.
@@ -162,17 +169,52 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
| `RACE_START` | — | unix-время старта (отправитель: `now + 6`) | только **наименьший id** в комнате, когда видит, что готовы все (поэтому двое «последних готовых» дают один старт; второй START от большего id игнорируется) |
| `RACE_CP` | индекс ворот | время гонки | игрок при прохождении ворот |
| `RACE_FINISH` | — | время финиша | игрок на последних воротах |
| `RACE_STATE` | биты: 0..7 пройдено ворот, 8 готов, 9..10 фаза | время финиша или -1 | «маяк» раз в секунду и при смене фазы; по нему догоняют потерявшие событие и зашедшие позже |
| `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 все флаги готовности сбрасываются. Ник и цвет в таблице — как у призрака (`Игрок id%1000`, `Color.from_hsv(id*0.618034…)`).
Время: на получении 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` изменён (новые RPC `submit_race`/`recv_race`) → после пуша `grid-world` сразу `tools/deploy_server.sh root@192.168.10.110`.
**Выкладка (общее правило):** старый клиент с новым сервером (и наоборот) **не работает вообще** — 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`, иначе окно подключится к реальному серверу и затрёт подставленное).
## Преследователи
@@ -214,6 +256,9 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
| `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_*`): подкат, бег по стене (+ сторона стены), рывок, подтягивание, «в воздухе». Присед передаётся как подкат; призрак отличает их по скорости. Скорость и двойной прыжок призрак считает сам по смещению позиции — в протокол они не входят.
@@ -351,6 +396,16 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
- Не мерялось: нагрузка 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 см (это нормально).
@@ -366,23 +421,17 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
экран «Нажми, чтобы начать» (аудио в браузере без жеста не стартует), мобильные текстуры ETC2/ASTC (`vram_texture_compression/for_mobile`; удвоит пакеты — делать отдельными `*.mobile.pck`), отключение SSAO/glow и `scaling_3d_scale` 0,6–0,75,
восстановление после ухода вкладки в фон (`submit_map` заново). Сначала измерить память и FPS на реальном устройстве.
### Ближайшее: ники и текстовый чат (ПЛАН, не реализовано — ждёт команды владельца)
### Ближайшее: текстовый чат (ПЛАН, не реализовано — ждёт команды владельца)
Выбор владельца (окт 2026). Реализация отложена; ниже решения, чтобы не придумывать заново. Делать можно отдельным агентом по этому плану.
**Ники**
- Хранение локально: `user://profile.cfg` (ConfigFile). При первом запуске в хабе — панель «Введите ник» (LineEdit, до 16 символов, Enter — подтвердить, Esc — пропустить; тогда прежний «Игрок N» и повторно не спрашивать). Работает на любой раскладке и со вставкой. Смена — командой чата `/ник Имя` или `/nick Имя`.
- Нормализация и на клиенте, и **обязательно на сервере**: обрезка пробелов, без управляющих символов и переводов строк, ≤ 16 символов, пустой → «Игрок N». Одинаковые ники в комнате допустимы.
- Сеть: клиент после подключения и при смене шлёт серверу ник; сервер хранит `peer_nicks`, при входе игрока в комнату (`submit_map`) отдаёт ему ники всех присутствующих, а остальным — его ник; на выходе чистит. Ники не должны просачиваться в чужие комнаты (как состояние игроков: `map_ready`, `_reset_room_state`).
- Показ: подпись над призраком (`net/ghost.gd`, сейчас «Игрок N»), итоги и HUD гонки (`maps/race/race_rules.gd`), чат. Единая точка: `Net.nick_of(id)` с запасным «Игрок N» и `Net.my_nick`.
Ники **сделаны** (раздел «Ники и рейтинг»): `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 с.
- **Ввод при наборе:** игрок читает управление ОПРОСОМ (`Input.is_action_pressed`/`get_vector`), поэтому фокус в LineEdit сам не остановит движение — нужен отдельный флаг блокировки (не `input_frozen`: его использует гонка на отсчёте, и закрытие чата не должно его сбрасывать). Мышь на время набора освобождать, потом снова захватывать. Открывающая клавиша не должна попасть в поле; `R` (готов в гонке) и прочие игровые клавиши в поле не срабатывают.
- **Ввод при наборе:** уже есть `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/race_game_test.sh` + `race_game_check.py`): ники видны у обоих и у позже зашедшего; смена ника доходит; чат только в своей комнате; лимиты длины/частоты; «[b]» не ломает оформление; при открытом чате бот не двигается, после закрытия — двигается; R/T в поле не срабатывают как действия. Регрессия: тест гонки, `mantle_test`, `measure_movement`. Кадры панели ника и чата через `--write-movie`: не перекрывают HUD гонки (сверху таймер, по центру отсчёт), кириллица рисуется.
**Проверка (по существу):** образец — `tools/test/nicks_lb_test.py`: чат только в своей комнате; лимиты длины/частоты; «[b]» не ломает оформление; при открытом чате бот не двигается, после закрытия — двигается; R/T в поле не срабатывают как действия. Регрессия: тест гонки, `mantle_test`, `measure_movement`. Кадры панели ника и чата через `--write-movie`: не перекрывают HUD гонки (сверху таймер, по центру отсчёт), кириллица рисуется.
**Выкладка:** меняется набор RPC → клиент и сервер вместе (порядок из «Деплой»), игрокам Ctrl+F5; старый клиент с новым сервером падает с «rpc node checksum failed».