From 666bebfb60ed32128c3ee642b31368129a012f47 Mon Sep 17 00:00:00 2001 From: aibrash Date: Thu, 29 Oct 2026 11:01:19 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8,=20=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B8=D0=B9=20=D1=80=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=20(?= =?UTF-8?q?=D1=82=D0=BE=D0=BF-10=20=D0=BF=D0=BE=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B0=D0=BC),=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20T?= =?UTF-8?q?ab,=20=D1=81=D0=B4=D0=B0=D1=87=D0=B0=20=D0=B2=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B5,=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=20Pa?= =?UTF-8?q?rkour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ники: профиль 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 Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1 --- README.md | 87 +++++-- core/lb_view.gd | 107 +++++++++ core/lb_view.gd.uid | 1 + core/overlay.gd | 220 ++++++++++++++++++ core/overlay.gd.uid | 1 + maps/hub/hub.gd | 100 +++++++- maps/parkour/parkour.gd | 141 ++++++++++++ maps/race/race_rules.gd | 137 ++++++++++- net/ghost.gd | 10 +- net/net.gd | 355 +++++++++++++++++++++++++++++ player/player.gd | 8 +- project.godot | 16 ++ tools/test/lb_client.gd | 111 +++++++++ tools/test/lb_client.gd.uid | 1 + tools/test/lb_client.tscn | 6 + tools/test/nick_ui_test.gd | 131 +++++++++++ tools/test/nick_ui_test.gd.uid | 1 + tools/test/nick_ui_test.tscn | 6 + tools/test/nicks_lb_test.py | 217 ++++++++++++++++++ tools/test/parkour_check.txt | 4 +- tools/test/parkour_run_test.gd | 105 +++++++++ tools/test/parkour_run_test.gd.uid | 1 + tools/test/parkour_run_test.py | 19 ++ tools/test/parkour_run_test.tscn | 6 + tools/test/race_bot.gd | 45 +++- tools/test/race_check.txt | 6 +- tools/test/race_game_test.sh | 4 +- tools/test/race_giveup_test.py | 97 ++++++++ tools/test/ui_tour.gd | 108 +++++++++ tools/test/ui_tour.gd.uid | 1 + tools/test/ui_tour.tscn | 6 + 31 files changed, 2014 insertions(+), 44 deletions(-) create mode 100644 core/lb_view.gd create mode 100644 core/lb_view.gd.uid create mode 100644 core/overlay.gd create mode 100644 core/overlay.gd.uid create mode 100644 tools/test/lb_client.gd create mode 100644 tools/test/lb_client.gd.uid create mode 100644 tools/test/lb_client.tscn create mode 100644 tools/test/nick_ui_test.gd create mode 100644 tools/test/nick_ui_test.gd.uid create mode 100644 tools/test/nick_ui_test.tscn create mode 100755 tools/test/nicks_lb_test.py create mode 100644 tools/test/parkour_run_test.gd create mode 100644 tools/test/parkour_run_test.gd.uid create mode 100755 tools/test/parkour_run_test.py create mode 100644 tools/test/parkour_run_test.tscn create mode 100755 tools/test/race_giveup_test.py create mode 100644 tools/test/ui_tour.gd create mode 100644 tools/test/ui_tour.gd.uid create mode 100644 tools/test/ui_tour.tscn diff --git a/README.md b/README.md index dd804b0..75a8bac 100644 --- a/README.md +++ b/README.md @@ -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": {"": {"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». diff --git a/core/lb_view.gd b/core/lb_view.gd new file mode 100644 index 0000000..5571ed2 --- /dev/null +++ b/core/lb_view.gd @@ -0,0 +1,107 @@ +class_name LbView +extends VBoxContainer +# Таблица рейтинга карты (топ-10 + «Вы: N-й»), данные берутся из Net.boards и обновляются сами по сигналу. +# Одна и та же таблица в панели Tab, в итогах гонки, итогах Parkour и будущей карты Metro: LbView.create("metro"). +# Ники выводятся обычными Label (не RichText), поэтому «[b]» в чужом нике оформление не ломает. + +const TEXT := Color(0.92, 0.88, 0.8) +const DIM := Color(0.62, 0.6, 0.56) +const MINE := Color(0.96, 0.76, 0.44) +const GOOD := Color(0.5, 0.85, 0.4) + +var map_id := "" +var font_size := 18 +var banner := false # сверху пометка «НОВЫЙ РЕКОРД КАРТЫ / ЛИЧНЫЙ РЕКОРД» (после submit_record) +var name_width := 190.0 + +static func create(id: String, size := 18, with_banner := false) -> LbView: + var v := LbView.new() + v.map_id = id + v.font_size = size + v.banner = with_banner + v.add_theme_constant_override("separation", 6) + return v + +static func make_label(text: String, size: int, color: Color) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", color) + l.add_theme_constant_override("outline_size", 3) + l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85)) + l.mouse_filter = Control.MOUSE_FILTER_IGNORE + return l + +# Тёмная полупрозрачная подложка в мрачном стиле +static func panel_style(margin := 20) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = Color(0.035, 0.04, 0.045, 0.86) + sb.border_color = Color(0.45, 0.30, 0.16, 0.9) + sb.set_border_width_all(2) + sb.set_content_margin_all(margin) + return sb + +func _ready() -> void: + Net.leaderboard_updated.connect(_on_updated) + Net.record_result.connect(_on_record) + render() + +func set_map(id: String) -> void: + map_id = id + render() + +func _on_updated(id: String) -> void: + if id == map_id: + render() + +func _on_record(id: String, _t: float, _rank: int, _pb: bool, _mr: bool) -> void: + if id == map_id: + render() + +func render() -> void: + for c in get_children(): + remove_child(c) + c.queue_free() + if banner: + var rec = Net.last_record.get(map_id) + if rec != null: + var txt := "" + if rec.map_record: + txt = "НОВЫЙ РЕКОРД КАРТЫ" + elif rec.personal: + txt = "НОВЫЙ ЛИЧНЫЙ РЕКОРД" + if txt != "": + var b := make_label(txt, font_size + 4, GOOD) + b.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + add_child(b) + var board = Net.boards.get(map_id) + if board == null: + add_child(make_label("—" if Net.connected else "Нет связи с сервером", font_size, DIM)) + return + var top: Array = board.top + if top.is_empty(): + add_child(make_label("Пока нет результатов", font_size, DIM)) + var grid := GridContainer.new() + grid.columns = 3 + grid.add_theme_constant_override("h_separation", 16) + grid.add_theme_constant_override("v_separation", 2) + add_child(grid) + for i in top.size(): + var mine: bool = board.my_rank == i + 1 + var col := MINE if mine else TEXT + var place := make_label("%d" % (i + 1), font_size, col if mine else DIM) + place.custom_minimum_size.x = font_size * 1.4 + place.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + grid.add_child(place) + var nm := make_label(str(top[i][0]), font_size, col) + nm.custom_minimum_size.x = name_width + nm.clip_text = true + nm.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + grid.add_child(nm) + var tm := make_label(Net.fmt_time(float(top[i][1])), font_size, col) + tm.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + grid.add_child(tm) + if board.my_rank > top.size(): + add_child(make_label("Вы: %d-й, %s" % [board.my_rank, Net.fmt_time(board.my_time)], font_size, MINE)) + elif board.my_rank == 0 and not top.is_empty(): + add_child(make_label("Вы: нет результата", font_size, DIM)) diff --git a/core/lb_view.gd.uid b/core/lb_view.gd.uid new file mode 100644 index 0000000..f1e5897 --- /dev/null +++ b/core/lb_view.gd.uid @@ -0,0 +1 @@ +uid://buo8m8ndkcb67 diff --git a/core/overlay.gd b/core/overlay.gd new file mode 100644 index 0000000..574beb0 --- /dev/null +++ b/core/overlay.gd @@ -0,0 +1,220 @@ +extends Node +# Автолоад «Overlay»: общие панели поверх любой карты — ввод ника (клавиша N, первый запуск в хабе) +# и таблица лидеров (Tab). Пока открыта панель ника, Net.input_blocked = true: игрок (player.gd) стоит и не +# обрабатывает игровые клавиши. Это отдельный флаг, НЕ player.input_frozen (его ведёт гонка на отсчёте). + +const TEXT := Color(0.92, 0.88, 0.8) +const DIM := Color(0.62, 0.6, 0.56) +const MINE := Color(0.96, 0.76, 0.44) + +var nick_open := false +var lb_open := false + +var _first_run := false +var _nick_layer: CanvasLayer +var _nick_title: Label +var _edit: LineEdit +var _lb_layer: CanvasLayer +var _lb_title: Label +var _lb_tabs: HBoxContainer +var _lb_holder: Control +var _lb_hint: Label +var _lb_view: LbView +var _lb_maps: Array = [] +var _lb_index := 0 +var _lb_for_map := "" + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + _build_nick_panel() + _build_lb_panel() + +# ---------- ввод ника ---------- + +func _build_nick_panel() -> void: + _nick_layer = CanvasLayer.new() + _nick_layer.layer = 20 + _nick_layer.visible = false + add_child(_nick_layer) + var root := Control.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.mouse_filter = Control.MOUSE_FILTER_IGNORE + _nick_layer.add_child(root) + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", LbView.panel_style(26)) + root.add_child(panel) + panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + panel.grow_horizontal = Control.GROW_DIRECTION_BOTH + panel.grow_vertical = Control.GROW_DIRECTION_BOTH + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 14) + panel.add_child(box) + _nick_title = LbView.make_label("Введите ник", 28, TEXT) + _nick_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(_nick_title) + _edit = LineEdit.new() + _edit.max_length = Net.NICK_MAX + _edit.custom_minimum_size = Vector2(320, 40) + _edit.add_theme_font_size_override("font_size", 22) + _edit.alignment = HORIZONTAL_ALIGNMENT_CENTER + _edit.placeholder_text = "до %d символов" % Net.NICK_MAX + _edit.context_menu_enabled = false + var sb := StyleBoxFlat.new() + sb.bg_color = Color(0.015, 0.018, 0.02, 0.95) + sb.border_color = Color(0.45, 0.30, 0.16, 0.9) + sb.set_border_width_all(1) + sb.set_content_margin_all(8) + _edit.add_theme_stylebox_override("normal", sb) + _edit.add_theme_stylebox_override("focus", sb) + _edit.add_theme_color_override("font_color", TEXT) + box.add_child(_edit) + _edit.focus_next = _edit.get_path() # Tab не уводит фокус + _edit.focus_previous = _edit.get_path() + _edit.text_submitted.connect(_on_nick_submitted) + _edit.gui_input.connect(_on_edit_gui_input) + var hint := LbView.make_label("Enter — подтвердить · Esc — пропустить", 16, DIM) + hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(hint) + +func open_nick(first_run := false) -> void: + if nick_open: + return + nick_open = true + _first_run = first_run + Net.input_blocked = true + if lb_open: + close_lb() + _nick_title.text = "Введите ник" if (first_run or Net.my_nick == "") else "Смена ника" + _edit.text = Net.my_nick + _edit.caret_column = _edit.text.length() + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _nick_layer.visible = true + _edit.grab_focus.call_deferred() # открывающая клавиша уже обработана и в поле не попадает + +func _close_nick(apply: bool) -> void: + if not nick_open: + return + if apply: + Net.set_nick(_edit.text) + elif _first_run: + Net.skip_nick() + nick_open = false + Net.input_blocked = false + _nick_layer.visible = false + _edit.release_focus() + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +func _on_nick_submitted(_text: String) -> void: + _close_nick(true) + +func _on_edit_gui_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + _edit.accept_event() + _close_nick(false) + +# ---------- таблица лидеров ---------- + +func _build_lb_panel() -> void: + _lb_layer = CanvasLayer.new() + _lb_layer.layer = 14 + _lb_layer.visible = false + add_child(_lb_layer) + var root := Control.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.mouse_filter = Control.MOUSE_FILTER_IGNORE + _lb_layer.add_child(root) + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", LbView.panel_style(18)) + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(panel) + # справа по центру: сверху остаётся таймер гонки, в центре — отсчёт, слева — место и ворота + panel.anchor_left = 1.0 + panel.anchor_right = 1.0 + panel.anchor_top = 0.5 + panel.anchor_bottom = 0.5 + panel.offset_right = -48.0 + panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN + panel.grow_vertical = Control.GROW_DIRECTION_BOTH + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 8) + panel.add_child(box) + _lb_title = LbView.make_label("", 24, TEXT) + box.add_child(_lb_title) + _lb_tabs = HBoxContainer.new() + _lb_tabs.add_theme_constant_override("separation", 14) + box.add_child(_lb_tabs) + var hold := VBoxContainer.new() + hold.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_child(hold) + _lb_holder = hold + _lb_hint = LbView.make_label("", 14, DIM) + box.add_child(_lb_hint) + +func _lb_map_list() -> Array: + if Net.TIMED_MAPS.has(Net.current_map): + return [Net.current_map] + return Net.TIMED_MAPS.keys() # хаб и карты без рейтинга: вкладки по всем картам + +func open_lb() -> void: + if lb_open or nick_open: + return + lb_open = true + _lb_for_map = Net.current_map + _lb_maps = _lb_map_list() + _lb_index = 0 + _lb_view = LbView.create(_lb_maps[0], 18) + _lb_holder.add_child(_lb_view) + _lb_layer.visible = true + _lb_refresh() + +func close_lb() -> void: + if not lb_open: + return + lb_open = false + _lb_layer.visible = false + if _lb_view != null: + _lb_holder.remove_child(_lb_view) + _lb_view.queue_free() + _lb_view = null + +func _lb_refresh() -> void: + var m: String = _lb_maps[_lb_index] + _lb_title.text = "ЛУЧШИЕ ВРЕМЕНА · " + Net.MAP_TITLES.get(m, m).to_upper() if _lb_maps.size() == 1 else "ЛУЧШИЕ ВРЕМЕНА" + for c in _lb_tabs.get_children(): + c.queue_free() + _lb_tabs.visible = _lb_maps.size() > 1 + for i in _lb_maps.size(): + _lb_tabs.add_child(LbView.make_label(Net.MAP_TITLES.get(_lb_maps[i], _lb_maps[i]), 18, MINE if i == _lb_index else DIM)) + _lb_hint.text = ("A / D — карта · " if _lb_maps.size() > 1 else "") + "Tab / Esc — закрыть" + _lb_view.set_map(m) + Net.request_board(m) + +func _process(_delta: float) -> void: + if lb_open and _lb_for_map != Net.current_map: + close_lb() # перешли на другую карту через портал + +func _unhandled_input(event: InputEvent) -> void: + if nick_open: + if event.is_action_pressed("ui_cancel"): + _close_nick(false) + return + if event.is_echo(): + return + if event.is_action_pressed("nick"): + open_nick(false) + elif event.is_action_pressed("leaderboard"): + if lb_open: + close_lb() + else: + open_lb() + elif lb_open and event.is_action_pressed("ui_cancel"): + close_lb() + elif lb_open and _lb_maps.size() > 1: + var d := 0 + if event.is_action_pressed("move_left"): + d = -1 + elif event.is_action_pressed("move_right"): + d = 1 + if d != 0: + _lb_index = posmod(_lb_index + d, _lb_maps.size()) + _lb_refresh() diff --git a/core/overlay.gd.uid b/core/overlay.gd.uid new file mode 100644 index 0000000..cdd68b2 --- /dev/null +++ b/core/overlay.gd.uid @@ -0,0 +1 @@ +uid://dcudxskqa5bx6 diff --git a/maps/hub/hub.gd b/maps/hub/hub.gd index 1dc1797..6b38ae2 100644 --- a/maps/hub/hub.gd +++ b/maps/hub/hub.gd @@ -12,7 +12,7 @@ const PORTALS := [ const ROOM_WIDTH := 28.0 const ROOM_DEPTH := 18.0 -const ROOM_HEIGHT := 6.0 +const ROOM_HEIGHT := 7.0 # +1 м: над порталами доски рейтинга, над ними — «CHOOSE A MAP» const WALL := 0.5 const PORTAL_SPACING := 4.5 @@ -35,6 +35,9 @@ func _ready() -> void: _build_room() _build_portals() _build_environment() + _build_boards() + if not Net.nick_asked: + Overlay.open_nick.call_deferred(true) # первый запуск: спрашиваем ник func _build_room() -> void: var body := StaticBody3D.new() @@ -101,6 +104,101 @@ func _build_portals() -> void: label.position = Vector3(0, ROOM_HEIGHT - 0.35, -ROOM_DEPTH * 0.5 + 0.1) add_child(label) +# ---------- доски рейтинга над порталами карт «на время» ---------- + +const BOARD_REFRESH := 60.0 # раз в столько секунд обновляем доски +const BOARD_ROWS := 3 +const BOARD_SIZE := Vector2(4.1, 1.45) +const BOARD_Y := 5.5 # центр доски; заголовок портала (Label3D) ниже, «CHOOSE A MAP» выше + +var _boards := {} # map_id -> {"names": Label3D, "times": Label3D} + +func _build_boards() -> void: + var z := -ROOM_DEPTH * 0.5 + 0.06 + for i in PORTALS.size(): + var info: Dictionary = PORTALS[i] + if not Net.TIMED_MAPS.has(info.map): + continue # песочница и Backrooms: рейтинга нет + var x := (i - (PORTALS.size() - 1) * 0.5) * PORTAL_SPACING + var border := MeshInstance3D.new() + var bq := QuadMesh.new() + bq.size = BOARD_SIZE + Vector2(0.1, 0.1) + bq.material = _unshaded(Color(0.30, 0.20, 0.11)) + border.mesh = bq + border.position = Vector3(x, BOARD_Y, z) + add_child(border) + var back := MeshInstance3D.new() + var q := QuadMesh.new() + q.size = BOARD_SIZE + q.material = _unshaded(Color(0.025, 0.028, 0.032)) + back.mesh = q + back.position = Vector3(x, BOARD_Y, z + 0.005) + add_child(back) + var title := _board_label(Net.MAP_TITLES.get(info.map, info.title).to_upper() + " · ТОП-3", 36, Color(0.96, 0.76, 0.44), HORIZONTAL_ALIGNMENT_CENTER) + title.position = Vector3(x, BOARD_Y + 0.52, z + 0.012) + add_child(title) + var names := _board_label("", 32, Color(0.92, 0.88, 0.8), HORIZONTAL_ALIGNMENT_LEFT) + names.position = Vector3(x - BOARD_SIZE.x * 0.5 + 0.12, BOARD_Y + 0.36, z + 0.012) + names.vertical_alignment = VERTICAL_ALIGNMENT_TOP + add_child(names) + var times := _board_label("", 32, Color(0.92, 0.88, 0.8), HORIZONTAL_ALIGNMENT_RIGHT) + times.position = Vector3(x + BOARD_SIZE.x * 0.5 - 0.12, BOARD_Y + 0.36, z + 0.012) + times.vertical_alignment = VERTICAL_ALIGNMENT_TOP + add_child(times) + _boards[info.map] = {"names": names, "times": times} + if _boards.is_empty(): + return + Net.leaderboard_updated.connect(_on_board_updated) + var timer := Timer.new() + timer.wait_time = 2.0 # быстрая проверка: ждём подключения, потом запрашиваем раз в BOARD_REFRESH + timer.autostart = true + timer.timeout.connect(_request_boards) + add_child(timer) + for m in _boards: + _on_board_updated(m) + Net.request_board(m) # при входе в хаб — свежие данные + +func _unshaded(c: Color) -> StandardMaterial3D: + var m := StandardMaterial3D.new() + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + m.albedo_color = c + return m + +func _board_label(text: String, size: int, color: Color, align: HorizontalAlignment) -> Label3D: + var l := Label3D.new() + l.text = text + l.font_size = size + l.pixel_size = 0.0056 + l.modulate = color + l.outline_size = 6 + l.outline_modulate = Color(0, 0, 0, 0.9) + l.horizontal_alignment = align + l.shaded = false + return l + +func _request_boards() -> void: + for m in _boards: + Net.request_board(m, int(BOARD_REFRESH * 1000.0)) + +func _on_board_updated(map_id: String) -> void: + if not _boards.has(map_id): + return + var board = Net.boards.get(map_id) + var n_lines := [] + var t_lines := [] + for i in BOARD_ROWS: + if board != null and i < board.top.size(): + var nick := str(board.top[i][0]) + if nick.length() > 13: + nick = nick.substr(0, 12) + "…" + n_lines.append("%d %s" % [i + 1, nick]) + t_lines.append(Net.fmt_time(float(board.top[i][1]))) + else: + n_lines.append("%d —" % (i + 1)) + t_lines.append("—") + _boards[map_id].names.text = "\n".join(n_lines) + _boards[map_id].times.text = "\n".join(t_lines) + func _build_environment() -> void: var env := Environment.new() env.background_mode = Environment.BG_COLOR diff --git a/maps/parkour/parkour.gd b/maps/parkour/parkour.gd index db385a6..aa486cd 100644 --- a/maps/parkour/parkour.gd +++ b/maps/parkour/parkour.gd @@ -9,10 +9,30 @@ const KILL_DEPTH := 32.0 # Ниже последнего чекпоинт const GATE_HEIGHT := 5.0 const GATE_HALF_WIDTH := 3.0 +# Забег на время. Старт — пересечение линии на конце стартовой платформы (Blender y = 14 -> Godot z = -14, см. layout.py: +# slab('start', -12, 12, -14, 14, ...)). Финиш — вершина: площадка summit (18x18 м) перед порталом; её центр в Godot — +# (PORTAL.x, верх, PORTAL.z + 3), потому что PORTAL = (SUM.x, SUM.y + 3) в Blender. Смерть таймер не останавливает. +const START_LINE_Z := -14.0 +const FINISH_SIZE := Vector3(14.0, 5.0, 14.0) +const RESULTS_TIME := 8.0 +const RATING_ID := "parkour" +const TEXT := Color(0.92, 0.88, 0.8) +const DIM := Color(0.62, 0.6, 0.56) +const GOOD := Color(0.5, 0.85, 0.4) + var player: Node3D var _lasers: Array = [] # {area, mat, mesh, mode, period, on, phase} var _hud_label: Label var _hud_timer := 0.0 +var run_active := false # таймер идёт +var run_done := false # финишировали, ждём R +var run_start_tick := 0 +var run_time := 0.0 # время финиша +var _prev_z := 0.0 +var _timer_label: Label +var _results_panel: PanelContainer +var _results_end := 0 +var _cp_items: Array = [] # {mat, sheet, label} — чтобы вернуть ворота в исходный цвет при перезапуске func _ready() -> void: Net.enter_map("parkour") @@ -31,6 +51,8 @@ func _ready() -> void: _add_ambience() _add_music() _add_exit() + _add_finish() + _prev_z = player.global_position.z # Цвета поверхностей — те же значения, что в Blender (kit.py: SOLID); освещение уже запечено в цветах вершин const SURFACES := { @@ -94,6 +116,7 @@ func _physics_process(delta: float) -> void: break _update_lasers() _update_rain() + _update_run() if _hud_timer > 0.0: _hud_timer -= delta _hud_label.modulate.a = clampf(_hud_timer / 0.5, 0.0, 1.0) @@ -147,6 +170,38 @@ func _build_hud() -> void: layer.add_child(_hud_label) _hud_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP) _hud_label.position.y = 90.0 + # таймер забега: небольшой, сверху по центру + _timer_label = Label.new() + _timer_label.text = Net.fmt_time(0.0) + _timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _timer_label.add_theme_font_size_override("font_size", 30) + _timer_label.add_theme_color_override("font_color", DIM) + _timer_label.add_theme_constant_override("outline_size", 6) + _timer_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9)) + _timer_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + layer.add_child(_timer_label) + _timer_label.anchor_left = 0.0 + _timer_label.anchor_right = 1.0 + _timer_label.offset_top = 14.0 + var hint := Label.new() + hint.text = "R — заново" + hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + hint.add_theme_font_size_override("font_size", 14) + hint.add_theme_color_override("font_color", DIM) + hint.mouse_filter = Control.MOUSE_FILTER_IGNORE + layer.add_child(hint) + hint.anchor_left = 0.0 + hint.anchor_right = 1.0 + hint.offset_top = 52.0 + # итоги: время и общий рейтинг + _results_panel = PanelContainer.new() + _results_panel.add_theme_stylebox_override("panel", LbView.panel_style(24)) + _results_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _results_panel.visible = false + layer.add_child(_results_panel) + _results_panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + _results_panel.grow_horizontal = Control.GROW_DIRECTION_BOTH + _results_panel.grow_vertical = Control.GROW_DIRECTION_BOTH func _emissive(color: Color, energy: float, alpha := 1.0) -> StandardMaterial3D: var m := StandardMaterial3D.new() @@ -196,6 +251,7 @@ func _add_checkpoint(pos: Vector3) -> void: cs.shape = shape area.add_child(cs) root.add_child(area) + _cp_items.append({"mat": mat, "sheet": sheet_mat, "label": label}) area.body_entered.connect(func(body: Node3D) -> void: if body != player or player.spawn_transform.origin.is_equal_approx(pos): return @@ -247,6 +303,91 @@ func _update_lasers() -> void: l.mat.emission_energy_multiplier = 2.4 if active else 0.06 l.mesh.visible = active or l.mode == 1 +# ---------- забег на время ---------- + +func _add_finish() -> void: + var area := Area3D.new() + area.collision_layer = 0 + area.collision_mask = 1 + area.position = Data.PORTAL + Vector3(0.0, FINISH_SIZE.y * 0.5, 3.0) + var cs := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = FINISH_SIZE + cs.shape = shape + area.add_child(cs) + add_child(area) + area.body_entered.connect(func(body: Node3D) -> void: + if body == player: + _finish_run()) + +func run_elapsed() -> float: + return (Time.get_ticks_msec() - run_start_tick) / 1000.0 + +# Старт: пересечение линии START_LINE_Z по ходу трассы (тот, кто телепортирован за линию, тоже стартует) +func _update_run() -> void: + var z: float = player.global_position.z + if not run_active and not run_done and _prev_z >= START_LINE_Z and z < START_LINE_Z: + run_active = true + run_start_tick = Time.get_ticks_msec() + _timer_label.add_theme_color_override("font_color", TEXT) + _prev_z = z + if run_active: + _timer_label.text = Net.fmt_time(run_elapsed()) + if _results_panel.visible and Time.get_ticks_msec() >= _results_end: + _results_panel.visible = false + +func _finish_run() -> void: + if not run_active: + return + run_active = false + run_done = true + run_time = run_elapsed() + _timer_label.text = Net.fmt_time(run_time) + _timer_label.add_theme_color_override("font_color", GOOD) + Net.submit_my_record(RATING_ID, run_time) # сервер сам проверит и ответит рейтингом + Net.request_board(RATING_ID) + _show_results() + +func _show_results() -> void: + for c in _results_panel.get_children(): + c.queue_free() + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 8) + _results_panel.add_child(box) + var t := LbView.make_label("ВЕРШИНА", 30, TEXT) + t.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(t) + var tm := LbView.make_label(Net.fmt_time(run_time), 36, GOOD) + tm.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(tm) + var rt := LbView.make_label("ОБЩИЙ РЕЙТИНГ", 18, DIM) + rt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(rt) + box.add_child(LbView.create(RATING_ID, 18, true)) + _results_panel.visible = true + _results_end = Time.get_ticks_msec() + int(RESULTS_TIME * 1000.0) + +# R: забег заново — на старт, таймер сброшен, чекпоинты снова не пройдены +func restart_run() -> void: + run_active = false + run_done = false + run_time = 0.0 + _results_panel.visible = false + _timer_label.text = Net.fmt_time(0.0) + _timer_label.add_theme_color_override("font_color", DIM) + for c in _cp_items: + c.mat.emission = Color(1.0, 0.5, 0.15) + c.sheet.emission = Color(1.0, 0.5, 0.15) + c.label.modulate = Color(1.0, 0.62, 0.3) + player.place(Transform3D(Basis.IDENTITY, Data.START)) + _prev_z = Data.START.z + +func _unhandled_input(event: InputEvent) -> void: + if Net.input_blocked: + return + if event.is_action_pressed("ready") and not event.is_echo(): + restart_run() + # ---------- выход ---------- func _add_exit() -> void: diff --git a/maps/race/race_rules.gd b/maps/race/race_rules.gd index c7b04f0..e0d0509 100644 --- a/maps/race/race_rules.gd +++ b/maps/race/race_rules.gd @@ -5,7 +5,9 @@ extends Node # - старт даёт единственный отправитель (наименьший id в комнате) и все считают время от одного момента; # - ворота засчитывает сам игрок и сообщает всем (CP/FINISH), порядок мест вычисляется одинаково у всех; # - раз в секунду каждый шлёт «маяк» (RACE_STATE) — по нему догоняют потерявшие событие и пришедшие позже. -# Биты маяка (a): 0..7 — пройдено ворот; 8 — готов; 9..10 — фаза отправителя (Phase). +# Биты маяка (a): 0..7 — пройдено ворот; 8 — готов; 9..10 — фаза отправителя (Phase); 11 — сдался. +# Сдача (клавиша G, удержание): игрока возвращает на стартовую площадку, время не пишется ни в итоги, ни в рейтинг; +# сдавшиеся считаются завершившими для конца круга. Рейтинг: финиш -> Net.submit_my_record(rating_id, время). const Data = preload("res://maps/race/race_data.gd") @@ -18,6 +20,7 @@ const EXTRA_ROW := 3.0 # запасные места: ряды поз const AFTER_FIRST_FINISH := 45.0 # гонка кончается через столько секунд после первого финиша const RESULTS_TIME := 8.0 const BEACON_PERIOD := 1.0 +const GIVEUP_HOLD := 1.5 # сколько секунд держать G, чтобы сдаться const GATE_HEIGHT := 5.0 # диапазон высот, в котором пересечение плоскости ворот засчитывается const GATE_MARGIN := 1.0 # запас по ширине сверх полуширины ворот const TELEPORT_JUMP := 4.0 # смещение за кадр больше этого — телепорт (возрождение), не пересечение ворот @@ -39,6 +42,8 @@ var results_end_tick := 0 var splits: Array = [] # свои сплиты круга var best_time := -1.0 var best_splits: Array = [] +var gave_up := {} # id -> true: сдался в этом круге +var rating_id := "race" # id карты в рейтинге сервера (Metro: map.rating_id = "metro") var debug := false # --race-log: факты в консоль (тесты) var _beacon_timer := 0.0 @@ -48,6 +53,7 @@ var _go_until := 0 var _split_until := 0 var _new_record := false var _music_on := false +var _giveup_hold := 0.0 # HUD var _timer_label: Label @@ -58,10 +64,14 @@ var _center_label: Label var _split_label: Label var _hint_label: Label var _results_panel: PanelContainer +var _giveup_label: Label +var _giveup_bar: ProgressBar func setup(m: Node3D) -> void: map = m player = m.player + if m.get("rating_id") != null: + rating_id = m.rating_id func _ready() -> void: debug = OS.get_cmdline_user_args().has("--race-log") @@ -107,6 +117,7 @@ func _reset_all() -> void: flags_ready.clear() passed.clear() finish.clear() + gave_up.clear() peer_phase.clear() known.clear() known[my_id] = true @@ -121,12 +132,13 @@ func _forget(id: int) -> void: flags_ready.erase(id) passed.erase(id) finish.erase(id) + gave_up.erase(id) peer_phase.erase(id) participants.erase(id) _log("игрок %d вышел" % id) func _name_of(id: int) -> String: - return "Игрок %d" % (id % 1000) # как у призрака (net/ghost.gd) + return Net.nick_of(id) # как над призраком (net/ghost.gd) func _color_of(id: int) -> Color: return Color.from_hsv(fmod(id * 0.618034, 1.0), 0.7, 1.0) # как у призрака @@ -149,6 +161,8 @@ func _beacon_bits() -> int: if flags_ready.get(my_id, false): bits |= 1 << 8 bits |= int(phase) << 9 + if gave_up.has(my_id): + bits |= 1 << 11 return bits func _send_beacon() -> void: @@ -177,6 +191,10 @@ func _on_race_event(id: int, kind: int, a: int, b: float) -> void: if participants.has(id): finish[id] = b passed[id] = Data.CHECKPOINTS.size() + Net.RACE_GIVEUP: + if participants.has(id) and not finish.has(id): + gave_up[id] = true + _log("игрок %d сдался" % id) Net.RACE_STATE: _on_beacon(id, a, b) @@ -189,6 +207,8 @@ func _on_beacon(id: int, a: int, b: float) -> void: passed[id] = maxi(passed.get(id, 0), a & 255) if b >= 0.0 and not finish.has(id): finish[id] = b + if ((a >> 11) & 1) == 1 and not finish.has(id): + gave_up[id] = true # Идёт чужой круг, в который мы не попали (зашли позже): бегаем свободно и ждём следующего func waiting_for_race() -> bool: @@ -202,6 +222,8 @@ func waiting_for_race() -> bool: # ---------- ожидание: готовность ---------- func _unhandled_input(event: InputEvent) -> void: + if Net.input_blocked: + return if event.is_action_pressed("ready") and not event.is_echo(): toggle_ready() @@ -248,6 +270,7 @@ func _on_start(sender: int, unix_start: float) -> void: participants = _room_ids() passed.clear() finish.clear() + gave_up.clear() splits.clear() _new_record = false for id in participants: @@ -271,7 +294,7 @@ func race_time() -> float: # ---------- гонка ---------- func _physics_process(_delta: float) -> void: - if phase != Phase.RACE or not participants.has(my_id) or finish.has(my_id) or player.is_dead: + if phase != Phase.RACE or not participants.has(my_id) or finish.has(my_id) or gave_up.has(my_id) or player.is_dead: _have_prev = false return var cur: Vector3 = player.global_position @@ -316,6 +339,7 @@ func _pass_gate(idx: int) -> void: if idx == n - 1: finish[my_id] = t _send(Net.RACE_FINISH, 0, t) + Net.submit_my_record(rating_id, t) # источник истины для рейтинга — сервер _new_record = best_time < 0.0 or t < best_time if _new_record: best_time = t @@ -348,6 +372,10 @@ func ranking() -> Array: return finish[a] < finish[b] or (finish[a] == finish[b] and a < b) if fa != fb: return fa + var ga := gave_up.has(a) + var gb := gave_up.has(b) + if ga != gb: + return gb # сдавшиеся — ниже остальных var pa: int = passed.get(a, 0) var pb: int = passed.get(b, 0) if pa != pb: @@ -371,27 +399,29 @@ func _check_race_end() -> void: if finish.has(id): if first < 0.0 or finish[id] < first: first = finish[id] - else: + elif not gave_up.has(id): all_done = false if all_done or (first >= 0.0 and race_time() >= first + AFTER_FIRST_FINISH): _end_race() func _end_race() -> void: + Net.request_board(rating_id) # к показу итогов — свежий рейтинг (другие могли финишировать позже нас) results.clear() var rank := ranking() for i in rank.size(): var id: int = rank[i] - results.append({"id": id, "place": i + 1, "time": finish.get(id, -1.0), "gates": passed.get(id, 0)}) + results.append({"id": id, "place": i + 1, "time": finish.get(id, -1.0), "gates": passed.get(id, 0), "gaveup": gave_up.has(id)}) results_end_tick = Time.get_ticks_msec() + int(RESULTS_TIME * 1000.0) _set_phase(Phase.RESULTS) _show_results() for r in results: - _log("итог: место %d игрок %d время %.3f ворот %d" % [r.place, r.id, r.time, r.gates]) + _log("итог: место %d игрок %d время %.3f ворот %d сдался=%s" % [r.place, r.id, r.time, r.gates, r.gaveup]) func _back_to_wait() -> void: participants.clear() passed.clear() finish.clear() + gave_up.clear() flags_ready.clear() _results_panel.visible = false player.input_frozen = false @@ -400,8 +430,32 @@ func _back_to_wait() -> void: _have_prev = false _set_phase(Phase.WAIT) +# Сдача: удерживаем G, пока не наполнится полоска; отпустил раньше — отмена +func _update_giveup(delta: float) -> void: + var can := phase == Phase.RACE and participants.has(my_id) and not finish.has(my_id) and not gave_up.has(my_id) and not Net.input_blocked + if can and Input.is_action_pressed("giveup"): + _giveup_hold += delta + if _giveup_hold >= GIVEUP_HOLD: + _giveup_hold = 0.0 + _give_up() + else: + _giveup_hold = 0.0 + +func _give_up() -> void: + gave_up[my_id] = true + _send(Net.RACE_GIVEUP, 0, race_time()) + player.place(Transform3D(Basis.IDENTITY, Data.START_SLOTS[0])) # назад на стартовую площадку, дальше бегаем свободно + map.set_gate_progress(0) + _have_prev = false + _split_label.text = "ВЫ СДАЛИСЬ" + _split_label.add_theme_color_override("font_color", DIM) + _split_until = Time.get_ticks_msec() + 3500 + _send_beacon() + _log("СДАЛСЯ (время гонки %.2f)" % race_time()) + func _process(delta: float) -> void: _refresh_room() + _update_giveup(delta) _beacon_timer += delta if _beacon_timer >= BEACON_PERIOD: _send_beacon() @@ -515,6 +569,37 @@ func _build_hud() -> void: _center_label.offset_left = 0.0 _center_label.offset_right = 0.0 + # индикатор сдачи: внизу по центру, полоска наполняется за GIVEUP_HOLD секунд + _giveup_label = _label(18, DIM, root) + _giveup_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _giveup_label.anchor_left = 0.0 + _giveup_label.anchor_right = 1.0 + _giveup_label.anchor_top = 1.0 + _giveup_label.anchor_bottom = 1.0 + _giveup_label.offset_top = -112.0 + _giveup_label.offset_bottom = -84.0 + _giveup_bar = ProgressBar.new() + _giveup_bar.show_percentage = false + _giveup_bar.min_value = 0.0 + _giveup_bar.max_value = GIVEUP_HOLD + var bg := StyleBoxFlat.new() + bg.bg_color = Color(0.03, 0.03, 0.035, 0.85) + var fg := StyleBoxFlat.new() + fg.bg_color = Color(0.78, 0.30, 0.20) + _giveup_bar.add_theme_stylebox_override("background", bg) + _giveup_bar.add_theme_stylebox_override("fill", fg) + _giveup_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(_giveup_bar) + _giveup_bar.anchor_left = 0.5 + _giveup_bar.anchor_right = 0.5 + _giveup_bar.anchor_top = 1.0 + _giveup_bar.anchor_bottom = 1.0 + _giveup_bar.offset_left = -110.0 + _giveup_bar.offset_right = 110.0 + _giveup_bar.offset_top = -80.0 + _giveup_bar.offset_bottom = -72.0 + _giveup_bar.visible = false + _results_panel = PanelContainer.new() var sb := StyleBoxFlat.new() sb.bg_color = Color(0.04, 0.045, 0.05, 0.84) @@ -532,9 +617,12 @@ func _build_hud() -> void: func _show_results() -> void: for c in _results_panel.get_children(): c.queue_free() + var cols := HBoxContainer.new() # слева итоги круга, справа общий рейтинг сервера + cols.add_theme_constant_override("separation", 44) + _results_panel.add_child(cols) var box := VBoxContainer.new() box.add_theme_constant_override("separation", 10) - _results_panel.add_child(box) + cols.add_child(box) var title := _label(36, TEXT, box) title.text = "ИТОГИ" title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER @@ -551,11 +639,21 @@ func _show_results() -> void: var nm := _label(26, c, grid) nm.text = _name_of(r.id) + (" (вы)" if mine else "") var tm := _label(26, TEXT if r.time >= 0.0 else DIM, grid) - tm.text = _fmt(r.time) if r.time >= 0.0 else "не финишировал (%d/%d)" % [r.gates, Data.CHECKPOINTS.size()] + if r.gaveup: + tm.text = "сдался" + else: + tm.text = _fmt(r.time) if r.time >= 0.0 else "не финишировал (%d/%d)" % [r.gates, Data.CHECKPOINTS.size()] if best_time >= 0.0: var rec := _label(20, DIM, box) rec.text = "Личный рекорд: %s%s" % [_fmt(best_time), " (новый!)" if _new_record else ""] rec.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + var side := VBoxContainer.new() + side.add_theme_constant_override("separation", 8) + cols.add_child(side) + var st := _label(26, TEXT, side) + st.text = "ОБЩИЙ РЕЙТИНГ" + st.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + side.add_child(LbView.create(rating_id, 18, true)) _results_panel.visible = true func _update_hud() -> void: @@ -569,13 +667,18 @@ func _update_hud() -> void: _split_label.visible = now < _split_until and racing if racing: var n: int = Data.CHECKPOINTS.size() - if finish.has(my_id): + if gave_up.has(my_id): + _timer_label.text = "СДАЛСЯ" + _timer_label.add_theme_color_override("font_color", DIM) + elif finish.has(my_id): _timer_label.text = _fmt(finish[my_id]) _timer_label.add_theme_color_override("font_color", Color(0.5, 0.85, 0.4)) else: _timer_label.text = _fmt(race_time()) _timer_label.add_theme_color_override("font_color", TEXT) - _place_label.text = "МЕСТО %d/%d" % [_my_place(), participants.size()] + _place_label.text = "МЕСТО %d/%d" % [_my_place(), participants.size() - gave_up.size()] + _place_label.visible = not gave_up.has(my_id) + _gates_label.visible = not gave_up.has(my_id) _gates_label.text = "ВОРОТА %d/%d" % [passed.get(my_id, 0), n] # центр: отсчёт, «СТАРТ!» @@ -594,6 +697,20 @@ func _update_hud() -> void: _center_label.add_theme_font_size_override("font_size", 110) _center_label.text = "СТАРТ!" + # сдача: подсказка первые секунды гонки, при удержании G — «СДАЮСЬ…» и полоска + var can_give := phase == Phase.RACE and participants.has(my_id) and not finish.has(my_id) and not gave_up.has(my_id) + _giveup_bar.visible = can_give and _giveup_hold > 0.0 + _giveup_bar.value = _giveup_hold + _giveup_label.visible = can_give and (_giveup_hold > 0.0 or race_time() < 12.0) + if _giveup_hold > 0.0: + _giveup_label.text = "СДАЮСЬ…" + _giveup_label.add_theme_font_size_override("font_size", 24) + _giveup_label.add_theme_color_override("font_color", TEXT) + else: + _giveup_label.text = "G (удерживать) — сдаться" + _giveup_label.add_theme_font_size_override("font_size", 16) + _giveup_label.add_theme_color_override("font_color", DIM) + # подсказка внизу _hint_label.visible = true if phase == Phase.WAIT: diff --git a/net/ghost.gd b/net/ghost.gd index 1182db1..95d4323 100644 --- a/net/ghost.gd +++ b/net/ghost.gd @@ -28,6 +28,7 @@ var pitch := 0.0 var flags := 0 var velocity := Vector3.ZERO # Сглаженная скорость по сетевым пакетам, м/с +var label: Label3D var anim: AnimationPlayer var skeleton: Skeleton3D var head_bone := -1 @@ -56,8 +57,9 @@ func setup(peer_id: int, pos: Vector3) -> void: anim.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL # кадры двигаем сами (см. _process) _paint_glow(model) - var label := Label3D.new() - label.text = "Игрок %d" % (id % 1000) + label = Label3D.new() + label.text = Net.nick_of(id) # обычный текст, не разметка: чужой ник не меняет оформление + Net.nick_changed.connect(_on_nick_changed) label.modulate = color label.billboard = BaseMaterial3D.BILLBOARD_ENABLED label.no_depth_test = true @@ -65,6 +67,10 @@ func setup(peer_id: int, pos: Vector3) -> void: label.pixel_size = 0.005 add_child(label) +func _on_nick_changed(changed_id: int) -> void: + if changed_id == id and label != null: + label.text = Net.nick_of(id) + # Материал «Glow» модели заменяем своим — с цветом игрока (общий материал модели остался бы одним на всех) func _paint_glow(model: Node) -> void: var mat := StandardMaterial3D.new() diff --git a/net/net.gd b/net/net.gd index e7b46b8..f397ec9 100644 --- a/net/net.gd +++ b/net/net.gd @@ -24,9 +24,25 @@ const RACE_START = 1 # b: unix-время старта (секунды, час const RACE_CP = 2 # a: индекс пройденных ворот, b: время гонки, с const RACE_FINISH = 3 # b: время финиша, с const RACE_STATE = 4 # «маяк» раз в секунду: a — биты состояния (см. race_rules.gd), b — время финиша или -1 +const RACE_GIVEUP = 5 # игрок сдался (b: время гонки на момент сдачи); в рейтинг ничего не идёт + +# Рейтинг лучших времён. Список карт «на время» и минимально допустимое время (страховка от мусора, не античит; +# подкручивается здесь — см. README «Ники и рейтинг»). Сервер принимает только эти id; клиент по ним же решает, где рисовать доски. +const TIMED_MAPS := {"race": {"min": 25.0}, "parkour": {"min": 40.0}, "metro": {"min": 25.0}} +const MAP_TITLES := {"race": "Гонка", "parkour": "Паркур", "metro": "Метро", "hub": "Хаб", "world": "Песочница", "backrooms": "Backrooms"} +const MAX_RECORD_TIME := 3600.0 +const BOARD_TOP := 10 # сколько строк уходит клиенту +const BOARD_KEEP := 100 # сколько записей на карту хранит сервер +const SAVE_EVERY := 2.0 # не чаще раза в столько секунд сервер пишет файл +const RECORDS_PER_MIN := 6 # лимит submit_record на игрока +const REQUESTS_PER_10S := 12 # лимит request_leaderboard на игрока +const NICK_MAX := 16 signal peer_respawned(pos: Vector3) # Чужой игрок возродился в точке pos signal race_event(id: int, kind: int, a: int, b: float) # Событие гонки от игрока id (кроме нас самих) +signal nick_changed(id: int) # Ник игрока id (или наш, id == своему) стал известен/изменился +signal leaderboard_updated(map_id: String) # Пришла таблица рейтинга +signal record_result(map_id: String, time: float, rank: int, personal: bool, map_record: bool) var is_server := false var ghosts := {} # peer_id -> Ghost @@ -44,17 +60,35 @@ var rock_owner := 0 # peer_id владельца (0 — ещё не на var dead_peers := {} # peer_id -> true, пока игрока поймали var hooked_player: Node = null +# Профиль игрока (клиент): user://profile.cfg. client_id — случайный 128-битный hex, опознаёт игрока в рейтинге, не показывается +var my_client_id := "" +var my_nick := "" # "" — не задан (показываем «Игрок N») +var nick_asked := false # уже спрашивали ник (ввёл или пропустил) — больше в хабе не спрашиваем +var nicks := {} # peer_id -> ник игроков комнаты (без нас самих) +var input_blocked := false # открыта панель ника: игровой ввод не работает (отдельно от player.input_frozen гонки) +var boards := {} # map_id -> {"top": Array, "my_rank": int, "my_time": float, "tick": msec} +var last_record := {} # map_id -> {"time", "rank", "personal", "map_record"}: ответ на последний submit_record + # Сервер var peer_maps := {} # peer_id -> карта ("" пока клиент не сообщил) var rock_owners := {} # карта -> peer_id владельца камня var rock_seen := {} # карта -> время (сек), когда владелец последний раз присылал камень +var peer_client := {} # peer_id -> client_id +var client_nick := {} # client_id -> ник (нормализованный) +var lb := {} # карта -> {client_id: {"nick": String, "time": float, "ts": int}} +var lb_dirty := false +var lb_last_save := 0.0 +var record_log := {} # peer_id -> [msec, ...] свои submit_record за последнюю минуту +var request_log := {} # peer_id -> [msec, ...] request_leaderboard за последние 10 с func _ready() -> void: # Сервер — это headless-запуск; --client делает клиентом и headless (боты для тестов, tools/test) is_server = DisplayServer.get_name() == "headless" and not OS.get_cmdline_user_args().has("--client") if is_server: + _load_leaderboard() _start_server() else: + _load_profile() _connect_client() # ---------- Сервер ---------- @@ -79,6 +113,9 @@ func _server_peer_left(id: int) -> void: print("- игрок ", id) var map: String = peer_maps.get(id, "") peer_maps.erase(id) + peer_client.erase(id) + record_log.erase(id) + request_log.erase(id) _leave_room(id, map) # Игроки комнаты map, кроме except @@ -137,6 +174,8 @@ func submit_map(map: String) -> void: if old != map: _leave_room(id, old) recv_map_ack.rpc_id(id, map) + if old != map: + _share_nicks(id, map) if rock_owners.get(map, 0) == 0: _set_rock_owner(map, id) else: @@ -182,8 +221,320 @@ func submit_race(kind: int, a: int, b: float) -> void: for other in _room(peer_maps.get(sender, ""), sender): recv_race.rpc_id(other, sender, kind, a, b) +# ---------- Сервер: ники ---------- + +# Нормализация ника (клиент и сервер используют одну функцию): переводы строк и табуляция -> пробел, остальные +# управляющие и невидимые символы выбрасываются, пробелы по краям обрезаются, не более NICK_MAX символов; пусто -> «Игрок N» +static func normalize_nick(raw: String, id: int) -> String: + var out := "" + for i in raw.length(): + var c := raw.unicode_at(i) + if c == 9 or c == 10 or c == 13: + out += " " + elif c < 32 or c == 127 or (c >= 0x80 and c <= 0x9F) or (c >= 0x200B and c <= 0x200F) \ + or (c >= 0x202A and c <= 0x202E) or (c >= 0x2066 and c <= 0x2069) or c == 0xFEFF or c == 0x2028 or c == 0x2029: + continue + else: + out += String.chr(c) + out = out.strip_edges() + if out.length() > NICK_MAX: + out = out.substr(0, NICK_MAX).strip_edges() + if out == "": + return default_nick(id) + return out + +static func default_nick(id: int) -> String: + return "Игрок %d" % (id % 1000) + +func _valid_client_id(cid: String) -> bool: + if cid.length() < 8 or cid.length() > 64: + return false + for i in cid.length(): + var c := cid.unicode_at(i) + if not ((c >= 48 and c <= 57) or (c >= 97 and c <= 122) or (c >= 65 and c <= 90)): + return false + return true + +# Игрок id пришёл в комнату map: ему — ники присутствующих, остальным — его ник +func _share_nicks(id: int, map: String) -> void: + for other in _room(map, id): + var n := _nick_of_peer(other) + if n != "": + recv_nick.rpc_id(id, other, n) + var mine := _nick_of_peer(id) + if mine != "": + for other in _room(map, id): + recv_nick.rpc_id(other, id, mine) + +func _nick_of_peer(id: int) -> String: + var cid: String = peer_client.get(id, "") + return client_nick.get(cid, "") if cid != "" else "" + +# Клиент -> сервер: профиль (после подключения и при смене ника). client_id опознаёт игрока в рейтинге. +@rpc("any_peer", "reliable") +func submit_profile(client_id: String, nick: String) -> void: + if not is_server: + return + var id := multiplayer.get_remote_sender_id() + if not _valid_client_id(client_id): + return + var norm := normalize_nick(nick, id) + peer_client[id] = client_id + client_nick[client_id] = norm + for map in lb: + if lb[map].has(client_id) and lb[map][client_id].nick != norm: + lb[map][client_id].nick = norm + lb_dirty = true + for other in _room(peer_maps.get(id, ""), id): + recv_nick.rpc_id(other, id, norm) + +# ---------- Сервер: рейтинг ---------- + +func _lb_path() -> String: + return "user://leaderboard.json" + +func _load_leaderboard() -> void: + lb = {} + for m in TIMED_MAPS: + lb[m] = {} + var path := _lb_path() + if not FileAccess.file_exists(path): + return + var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) + if not (parsed is Dictionary) or not (parsed.get("maps") is Dictionary): + push_warning("leaderboard.json испорчен, начинаю с пустого (файл сохранён как .bad)") + DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".bad") + return + var total := 0 + for m in TIMED_MAPS: + var entries = parsed.maps.get(m) + if not (entries is Dictionary): + continue + for cid in entries: + var e = entries[cid] + if not (cid is String) or not _valid_client_id(cid) or not (e is Dictionary): + continue + var t = e.get("time") + if not (t is float or t is int) or not is_finite(t) or t <= 0.0 or t > MAX_RECORD_TIME: + continue + var nick = e.get("nick", "") + lb[m][cid] = {"nick": normalize_nick(nick if nick is String else "", 0), "time": float(t), "ts": int(e.get("ts", 0)) if (e.get("ts") is float or e.get("ts") is int) else 0} + _trim_board(m) + total += lb[m].size() + print("Рейтинг загружен: %d записей" % total) + +func _trim_board(m: String) -> void: + var board: Dictionary = lb[m] + if board.size() <= BOARD_KEEP: + return + var ids := _sorted_ids(m) + for i in range(BOARD_KEEP, ids.size()): + board.erase(ids[i]) + +# client_id карты по возрастанию времени (при равенстве — кто раньше поставил) +func _sorted_ids(m: String) -> Array: + var board: Dictionary = lb[m] + var ids := board.keys() + ids.sort_custom(func(a, b) -> bool: + var ea: Dictionary = board[a] + var eb: Dictionary = board[b] + if ea.time != eb.time: + return ea.time < eb.time + if ea.ts != eb.ts: + return ea.ts < eb.ts + return a < b) + return ids + +# Атомарная запись: во временный файл, затем переименование +func _save_leaderboard() -> void: + lb_dirty = false + lb_last_save = Time.get_ticks_msec() / 1000.0 + var path := _lb_path() + var tmp := path + ".tmp" + var f := FileAccess.open(tmp, FileAccess.WRITE) + if f == null: + push_warning("Не удалось записать рейтинг: %s" % error_string(FileAccess.get_open_error())) + lb_dirty = true + return + f.store_string(JSON.stringify({"version": 1, "maps": lb})) + f.close() + var err := DirAccess.rename_absolute(ProjectSettings.globalize_path(tmp), ProjectSettings.globalize_path(path)) + if err != OK: + push_warning("Не удалось переименовать рейтинг: %s" % error_string(err)) + lb_dirty = true + +func _server_flush_leaderboard() -> void: + if lb_dirty and Time.get_ticks_msec() / 1000.0 - lb_last_save >= SAVE_EVERY: + _save_leaderboard() + +func _notification(what: int) -> void: + if is_server and what == NOTIFICATION_WM_CLOSE_REQUEST and lb_dirty: + _save_leaderboard() + +# Лимит частоты: не более limit событий за window_ms на игрока (лишнее молча отбрасывается) +func _rate_ok(log: Dictionary, id: int, limit: int, window_ms: int) -> bool: + var now := Time.get_ticks_msec() + var arr: Array = log.get(id, []) + while not arr.is_empty() and now - arr[0] > window_ms: + arr.pop_front() + log[id] = arr + if arr.size() >= limit: + return false + arr.append(now) + return true + +func _board_top(m: String) -> Array: + var out := [] + var ids := _sorted_ids(m) + for i in mini(BOARD_TOP, ids.size()): + var e: Dictionary = lb[m][ids[i]] + out.append([e.nick, e.time, e.ts]) + return out + +func _send_board(peer: int, m: String) -> void: + var cid: String = peer_client.get(peer, "") + var rank := 0 + var my_time := 0.0 + if cid != "" and lb[m].has(cid): + my_time = lb[m][cid].time + rank = _sorted_ids(m).find(cid) + 1 + recv_leaderboard.rpc_id(peer, m, _board_top(m), rank, my_time) + +# Клиент -> сервер: мой результат на карте на время. Берётся лучшее время игрока; ответ — recv_record_result + recv_leaderboard. +@rpc("any_peer", "reliable") +func submit_record(map_id: String, time: float) -> void: + if not is_server: + return + var id := multiplayer.get_remote_sender_id() + if not _rate_ok(record_log, id, RECORDS_PER_MIN, 60000): + return + if not TIMED_MAPS.has(map_id) or peer_maps.get(id, "") != map_id: + return + if not is_finite(time) or time < float(TIMED_MAPS[map_id].min) or time > MAX_RECORD_TIME: + return + var cid: String = peer_client.get(id, "") + if cid == "": + return + var nick: String = client_nick.get(cid, default_nick(id)) + var board: Dictionary = lb[map_id] + var old = board.get(cid) + var personal: bool = old == null or time < old.time + if personal: + board[cid] = {"nick": nick, "time": time, "ts": int(Time.get_unix_time_from_system())} + _trim_board(map_id) + lb_dirty = true + elif old.nick != nick: + old.nick = nick + lb_dirty = true + var rank := _sorted_ids(map_id).find(cid) + 1 + print("[рекорд] %s %s %.3f (место %d%s)" % [map_id, nick, time, rank, ", личный" if personal else ""]) + recv_record_result.rpc_id(id, map_id, time, rank, personal, personal and rank == 1) + _send_board(id, map_id) + +# Клиент -> сервер: пришли топ-10 карты и моё место +@rpc("any_peer", "reliable") +func request_leaderboard(map_id: String) -> void: + if not is_server: + return + var id := multiplayer.get_remote_sender_id() + if not _rate_ok(request_log, id, REQUESTS_PER_10S, 10000): + return + if not TIMED_MAPS.has(map_id): + return + _send_board(id, map_id) + # ---------- Клиент ---------- +func _profile_path() -> String: + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--profile="): # для тестов: не трогаем настоящий профиль + return arg.substr("--profile=".length()) + return "user://profile.cfg" + +func _load_profile() -> void: + var cf := ConfigFile.new() + cf.load(_profile_path()) + my_client_id = str(cf.get_value("profile", "client_id", "")) + if not _valid_client_id(my_client_id): + my_client_id = Crypto.new().generate_random_bytes(16).hex_encode() + cf.set_value("profile", "client_id", my_client_id) + cf.save(_profile_path()) + var saved := str(cf.get_value("profile", "nick", "")) + my_nick = normalize_nick(saved, 0) if saved.strip_edges() != "" else "" + nick_asked = bool(cf.get_value("profile", "asked", my_nick != "")) + +func _save_profile() -> void: + var cf := ConfigFile.new() + cf.load(_profile_path()) + cf.set_value("profile", "client_id", my_client_id) + cf.set_value("profile", "nick", my_nick) + cf.set_value("profile", "asked", nick_asked) + cf.save(_profile_path()) + +# Задать свой ник (пустая строка — «Игрок N»). Сохраняется, уходит серверу и комнате. +func set_nick(raw: String) -> void: + var n := normalize_nick(raw, 0) if raw.strip_edges() != "" else "" + my_nick = n + nick_asked = true + _save_profile() + _send_profile() + nick_changed.emit(own_id()) + +# Пропустили ввод ника: больше не спрашиваем +func skip_nick() -> void: + nick_asked = true + _save_profile() + +func _send_profile() -> void: + if connected: + submit_profile.rpc_id(1, my_client_id, my_nick) + +func own_id() -> int: + return multiplayer.get_unique_id() if connected else 1 + +# Единая точка: ник игрока id (наш — my_nick), запасной вариант «Игрок N» +func nick_of(id: int) -> String: + if id == own_id(): + return my_nick if my_nick != "" else default_nick(id) + return nicks.get(id, default_nick(id)) + +# Время рекорда «мм:сс.мс» +static func fmt_time(t: float) -> String: + t = maxf(t, 0.0) + var ms := int(round(t * 1000.0)) + return "%02d:%02d.%03d" % [ms / 60000, (ms / 1000) % 60, ms % 1000] + +func submit_my_record(map_id: String, time: float) -> void: + last_record.erase(map_id) + if connected: + submit_record.rpc_id(1, map_id, time) + +# Запросить таблицу карты (не чаще, чем раз в max_age_ms) +func request_board(map_id: String, max_age_ms := 0) -> void: + if not connected or not TIMED_MAPS.has(map_id): + return + var b = boards.get(map_id) + if b != null and Time.get_ticks_msec() - b.tick < max_age_ms: + return + request_leaderboard.rpc_id(1, map_id) + +@rpc("authority", "reliable") +func recv_nick(id: int, nick: String) -> void: + if not map_ready: + return + nicks[id] = normalize_nick(nick, id) + nick_changed.emit(id) + +@rpc("authority", "reliable") +func recv_leaderboard(map_id: String, top: Array, my_rank: int, my_time: float) -> void: + boards[map_id] = {"top": top, "my_rank": my_rank, "my_time": my_time, "tick": Time.get_ticks_msec()} + leaderboard_updated.emit(map_id) + +@rpc("authority", "reliable") +func recv_record_result(map_id: String, time: float, rank: int, personal: bool, map_record: bool) -> void: + last_record[map_id] = {"time": time, "rank": rank, "personal": personal, "map_record": map_record} + record_result.emit(map_id, time, rank, personal, map_record) + func _server_url() -> String: for arg in OS.get_cmdline_user_args(): if arg.begins_with("--server-url="): @@ -209,6 +560,7 @@ func _connect_client() -> void: func _on_connected() -> void: connected = true + _send_profile() # раньше submit_map: при входе в комнату сервер уже знает наш ник submit_map.rpc_id(1, current_map) func _on_lost() -> void: @@ -230,6 +582,7 @@ func _reset_room_state() -> void: rock_owner = 0 chaser_states.clear() hooked_player = null + nicks.clear() map_ready = false # Мы перешли на другую карту (вызывает сама карта при загрузке, идемпотентно) @@ -308,6 +661,7 @@ func recv_left(id: int) -> void: ghost.queue_free() ghosts.erase(id) dead_peers.erase(id) + nicks.erase(id) # Сообщаем остальным о смерти и возрождении своего игрока func _hook_player(player: Node) -> void: @@ -324,6 +678,7 @@ func _send_life(dead: bool, player: Node) -> void: func _process(delta: float) -> void: if is_server: _server_check_rock_owner() + _server_flush_leaderboard() return if not connected or not map_ready: return diff --git a/player/player.gd b/player/player.gd index 2fefa48..de5875b 100644 --- a/player/player.gd +++ b/player/player.gd @@ -238,8 +238,8 @@ func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel"): Input.mouse_mode = Input.MOUSE_MODE_VISIBLE - if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not Net.input_blocked: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # (панель ника открыта — мышь нужна на экране) if is_dead: return @@ -250,7 +250,7 @@ func _unhandled_input(event: InputEvent) -> void: head.rotate_x(-event.relative.y * MOUSE_SENSITIVITY * zoom_factor) head.rotation.x = clamp(head.rotation.x, deg_to_rad(-80), deg_to_rad(80)) - if input_frozen: + if input_frozen or Net.input_blocked: return if event.is_action_pressed("crouch"): @@ -279,7 +279,7 @@ func _physics_process(delta: float) -> void: _process_death(delta) return - if input_frozen: + if input_frozen or Net.input_blocked: # input_blocked — открыта панель ника (core/overlay.gd), не зависит от гонки # Стоим на месте (только падаем, если под ногами пусто) velocity.x = 0.0 velocity.z = 0.0 diff --git a/project.godot b/project.godot index ceb5e51..f8a5baf 100644 --- a/project.godot +++ b/project.godot @@ -19,6 +19,7 @@ config/icon="res://icon.svg" Net="*res://net/net.gd" MapLoader="*res://core/map_loader.gd" +Overlay="*res://core/overlay.gd" [display] @@ -76,6 +77,21 @@ ready={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +nick={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":78,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +leaderboard={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +giveup={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":71,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [physics] diff --git a/tools/test/lb_client.gd b/tools/test/lb_client.gd new file mode 100644 index 0000000..f1f6acc --- /dev/null +++ b/tools/test/lb_client.gd @@ -0,0 +1,111 @@ +extends Node +# Клиент-сценарий для проверки ников и рейтинга (см. nicks_lb_test.py): выполняет команды из --cmds=a;b;c +# и печатает факты строками «[имя] ФАКТ …». Без игрока и карты, если не вызвана команда scene:hub. +# godot --headless --path <копия> res://tools/test/lb_client.tscn -- --client --name=A --profile=/tmp/a.cfg --cmds="nick:Алиса;map:race;sleep:2;show;quit" +# Команды: wait_connected, nick:<текст>, map:, scene:hub, sleep:<с>, waitghosts:, show, rec:<карта>:<время>, +# recn:<карта>:<сколько>:<время0> (подряд, время каждый раз на 1 с меньше), req:<карта>, reqn:<карта>:<сколько>, +# bad (заведомо неверные submit_record), last:<карта>, quit. + +var label := "?" +var cmds: Array = [] +var results := 0 +var board_updates := 0 + +func _ready() -> void: + for a in OS.get_cmdline_user_args(): + if a.begins_with("--name="): label = a.substr(7) + if a.begins_with("--cmds="): cmds = a.substr(7).split(";") + Net.record_result.connect(func(m, t, r, pb, mr): results += 1; say("ФАКТ РЕЗУЛЬТАТ %s время=%.3f место=%d личный=%s рекорд_карты=%s" % [m, t, r, pb, mr])) + Net.leaderboard_updated.connect(func(_m): board_updates += 1) + _run.call_deferred() + +func say(text: String) -> void: + print("[%s] %s" % [label, text]) + +func _wait_until(cond: Callable, timeout := 8.0) -> bool: + var t := 0.0 + while not cond.call(): + await get_tree().create_timer(0.1).timeout + t += 0.1 + if t > timeout: + return false + return true + +func _board_text(m: String) -> String: + var b = Net.boards.get(m) + if b == null: + return "нет данных" + var parts := [] + for e in b.top: + parts.append("%s:%.3f" % [e[0], e[1]]) + return "n=%d место=%d моё=%.3f топ=%s" % [b.top.size(), b.my_rank, b.my_time, "|".join(parts)] + +func _run() -> void: + for c in cmds: + var p: PackedStringArray = c.split(":", true, 3) + match p[0]: + "wait_connected": + say("ФАКТ ПОДКЛЮЧЁН %s" % await _wait_until(func(): return Net.connected)) + "nick": + Net.set_nick(c.substr(5)) + "map": + Net.enter_map(p[1]) + say("ФАКТ КАРТА %s готова=%s" % [p[1], await _wait_until(func(): return Net.map_ready)]) + "scene": + add_child((load("res://maps/hub/hub.tscn") as PackedScene).instantiate()) + say("ФАКТ ХАБ готов=%s" % await _wait_until(func(): return Net.map_ready)) + "sleep": + await get_tree().create_timer(float(p[1])).timeout + "waitghosts": + say("ФАКТ ПРИЗРАКИ %s" % await _wait_until(func(): return Net.ghosts.size() >= int(p[1]), 10.0)) + await get_tree().create_timer(0.7).timeout + "show": + var gl := [] + for id in Net.ghosts: + var g = Net.ghosts[id] + var l: Label3D = g.label + gl.append("%d=%s(%s)" % [id, l.text, Net.nick_of(id)]) + var ps := [] + for id in Net.nicks: + ps.append("%d=%s" % [id, Net.nicks[id]]) + ps.sort() + gl.sort() + say("ФАКТ НИКИ свой=%s ники=%s призраки=%s" % [Net.nick_of(Net.own_id()), ",".join(ps), ",".join(gl)]) + "rec": + Net.submit_my_record(p[1], float(p[2])) + await get_tree().create_timer(0.6).timeout + "recn": + for i in int(p[2]): + Net.submit_my_record(p[1], float(p[3]) - i) + await get_tree().create_timer(1.5).timeout + say("ФАКТ ОТВЕТОВ_НА_ЗАПИСИ %d" % results) + "req": + var before := board_updates + Net.request_board(p[1]) + await _wait_until(func(): return board_updates > before, 3.0) + say("ФАКТ ТАБЛИЦА %s %s" % [p[1], _board_text(p[1])]) + "reqn": + var before := board_updates + for i in int(p[2]): + Net.request_leaderboard.rpc_id(1, p[1]) + await get_tree().create_timer(1.5).timeout + say("ФАКТ ОТВЕТОВ_НА_ЗАПРОСЫ %d" % (board_updates - before)) + "bad": + var before := results + Net.submit_record.rpc_id(1, "zzz", 60.0) # нет такой карты + Net.submit_record.rpc_id(1, "race", 5.0) # быстрее допустимого + Net.submit_record.rpc_id(1, "race", 99999.0) # дольше допустимого + Net.submit_record.rpc_id(1, "race", NAN) + Net.submit_record.rpc_id(1, "race", -3.0) + Net.submit_record.rpc_id(1, "parkour", 90.0) # карта, на которой мы не находимся + Net.request_leaderboard.rpc_id(1, "zzz") + await get_tree().create_timer(1.5).timeout + say("ФАКТ НЕВЕРНЫЕ_ПРИНЯТЫ %d" % (results - before)) + "last": + say("ФАКТ ПОСЛЕДНИЙ %s" % str(Net.last_record.get(p[1]))) + "quit": + say("ГОТОВО") + get_tree().quit() + return + say("ГОТОВО") + get_tree().quit() diff --git a/tools/test/lb_client.gd.uid b/tools/test/lb_client.gd.uid new file mode 100644 index 0000000..f94b0cb --- /dev/null +++ b/tools/test/lb_client.gd.uid @@ -0,0 +1 @@ +uid://cn2e2m283kvpr diff --git a/tools/test/lb_client.tscn b/tools/test/lb_client.tscn new file mode 100644 index 0000000..7581854 --- /dev/null +++ b/tools/test/lb_client.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/test/lb_client.gd" id="1"] + +[node name="T" type="Node"] +script = ExtResource("1") diff --git a/tools/test/nick_ui_test.gd b/tools/test/nick_ui_test.gd new file mode 100644 index 0000000..1803759 --- /dev/null +++ b/tools/test/nick_ui_test.gd @@ -0,0 +1,131 @@ +extends Node +# Панель ника и таблица лидеров «по существу» (без сети и окна): настоящая карта Race, игрок, Overlay, события клавиш. +# godot --headless --path <проект> res://tools/test/nick_ui_test.tscn -- --client --profile=/tmp/ui_profile.cfg +# Проверяет: игрок двигается; после N панель открыта, а «вперёд» не двигает; R/N/Tab/G в поле — не действия (R не меняет готовность, +# Tab не открывает таблицу), буквы попадают в поле, открывающая клавиша — нет; кириллица и вставка; предел 16 символов; +# Enter сохраняет ник в профиль; после закрытия игрок снова двигается; Esc при первом запуске — пропуск без ника и «больше не спрашивать»; +# Tab открывает/закрывает таблицу, игрок при этом не заблокирован. Выводит «ДА/НЕТ», в конце «UI ПРОЙДЕН / ПРОВАЛЕН». + +var ok := true +var level +var p +var rules + +func check(cond: bool, text: String) -> void: + print((" ДА " if cond else " НЕТ ") + text) + if not cond: + ok = false + +func key(code: int, pressed: bool, unicode := 0) -> void: + var ev := InputEventKey.new() + ev.physical_keycode = code as Key + ev.keycode = code as Key + ev.unicode = unicode + ev.pressed = pressed + Input.parse_input_event(ev) + +func tap(code: int, unicode := 0) -> void: + key(code, true, unicode) + await get_tree().process_frame + key(code, false, unicode) + await get_tree().process_frame + +func hold_forward(sec: float) -> float: + var start: Vector3 = p.global_position + Input.action_press("move_forward") + await get_tree().create_timer(sec).timeout + Input.action_release("move_forward") + await get_tree().create_timer(0.2).timeout + var d: Vector3 = p.global_position - start + return Vector2(d.x, d.z).length() + +func _ready() -> void: + _run.call_deferred() + +func _run() -> void: + level = (load("res://maps/race/race.tscn") as PackedScene).instantiate() + add_child(level) + await get_tree().create_timer(1.0).timeout + p = level.get_node("Player") + rules = level.get_node("Rules") + check(Net.nick_asked == false and Net.my_nick == "", "свежий профиль: ник не задан, не спрашивали") + check(Net.my_client_id.length() == 32, "создан client_id из 32 hex-символов (128 бит)") + var mouse_works := Input.mouse_mode == Input.MOUSE_MODE_CAPTURED + var moved := await hold_forward(0.6) + check(moved > 1.0, "без панели игрок бежит вперёд (%.2f м)" % moved) + + print("-- открываем панель клавишей N") + await tap(KEY_N, 110) + await get_tree().create_timer(0.2).timeout + check(Overlay.nick_open and Net.input_blocked, "N открыла панель и поставила флаг блокировки") + check(not p.input_frozen, "флаг гонки input_frozen не тронут") + check(Overlay._edit.has_focus() and Overlay._edit.text == "", "поле в фокусе, открывающая клавиша «n» в поле не попала (текст «%s»)" % Overlay._edit.text) + moved = await hold_forward(0.7) + check(moved < 0.05, "при открытой панели «вперёд» не двигает игрока (%.3f м)" % moved) + var was_ready: bool = rules.flags_ready.get(rules.my_id, false) + await tap(KEY_R, 114) + await tap(KEY_N, 110) + await tap(KEY_TAB) + await tap(KEY_G, 103) + check(rules.flags_ready.get(rules.my_id, false) == was_ready, "R в поле не переключает «готов» в гонке") + check(Overlay.nick_open and not Overlay.lb_open, "N и Tab в поле не закрыли панель и не открыли таблицу") + check(Overlay._edit.text == "rng", "R, N, G попали в поле как буквы, Tab — нет (текст «%s»)" % Overlay._edit.text) + Overlay._edit.text = "" + Overlay._edit.insert_text_at_caret("Привет, [b]мир[/b] и ещё длинный хвост") # как вставка из буфера + check(Overlay._edit.text.length() <= 16 and Overlay._edit.text.begins_with("Привет"), "вставка с кириллицей обрезана до 16 символов: «%s»" % Overlay._edit.text) + Overlay._edit.text = "" + await tap(KEY_B, 1080) # «и» с unicode — как набор на русской раскладке + await tap(KEY_K, 1082) + await tap(KEY_SPACE, 32) + await tap(KEY_A, 1103) + check(Overlay._edit.text == "ик я", "набор кириллицы клавишами: «%s»" % Overlay._edit.text) + Overlay._edit.text = " Мир [b]x " + await tap(KEY_ENTER) + await get_tree().create_timer(0.2).timeout + check(not Overlay.nick_open and not Net.input_blocked, "Enter закрыл панель, блокировка снята") + check(Net.my_nick == "Мир [b]x" and Net.nick_of(Net.own_id()) == "Мир [b]x", "ник сохранён и обрезан по краям: «%s»" % Net.my_nick) + var cf := ConfigFile.new() + cf.load("user://profile.cfg" if not _prof().begins_with("--") else _prof().substr(10)) + check(cf.get_value("profile", "nick", "") == "Мир [b]x" and cf.get_value("profile", "asked", false) == true and str(cf.get_value("profile", "client_id", "")).length() == 32, "профиль записан в файл (ник, asked, client_id)") + if mouse_works: + check(Input.mouse_mode == Input.MOUSE_MODE_CAPTURED, "после закрытия мышь снова захвачена") + else: + print(" -- режим мыши в headless не хранится: проверка захвата мыши пропущена (смотреть руками)") + moved = await hold_forward(0.6) + check(moved > 1.0, "после закрытия игрок снова бежит (%.2f м)" % moved) + + print("-- смена ника: в поле стоит текущий") + await tap(KEY_N, 110) + await get_tree().create_timer(0.2).timeout + check(Overlay.nick_open and Overlay._edit.text == "Мир [b]x" and Overlay._nick_title.text == "Смена ника", "N снова открывает панель, ник в поле, заголовок «Смена ника»") + await tap(KEY_ESCAPE) + await get_tree().create_timer(0.2).timeout + check(not Overlay.nick_open and Net.my_nick == "Мир [b]x", "Esc при смене — отмена, ник прежний") + + print("-- первый запуск, Esc: пропуск") + Net.my_nick = "" + Net.nick_asked = false + Overlay.open_nick(true) + await get_tree().create_timer(0.2).timeout + await tap(KEY_ESCAPE) + await get_tree().create_timer(0.2).timeout + check(not Overlay.nick_open and Net.nick_asked and Net.my_nick == "" and Net.nick_of(Net.own_id()).begins_with("Игрок "), "Esc: панель закрыта, больше не спрашиваем, ник «%s»" % Net.nick_of(Net.own_id())) + + print("-- таблица лидеров (Tab)") + await tap(KEY_TAB) + check(Overlay.lb_open and not Net.input_blocked, "Tab открывает таблицу, игрок не заблокирован") + moved = await hold_forward(0.5) + check(moved > 0.8, "с таблицей игрок двигается (%.2f м)" % moved) + await tap(KEY_TAB) + check(not Overlay.lb_open, "повторный Tab закрывает") + await tap(KEY_TAB) + await tap(KEY_ESCAPE) + check(not Overlay.lb_open, "Esc закрывает таблицу") + print("\nUI ПРОЙДЕН" if ok else "\nUI ПРОВАЛЕН") + get_tree().quit(0 if ok else 1) + +func _prof() -> String: + for a in OS.get_cmdline_user_args(): + if a.begins_with("--profile="): + return a + return "user://profile.cfg" diff --git a/tools/test/nick_ui_test.gd.uid b/tools/test/nick_ui_test.gd.uid new file mode 100644 index 0000000..a58b672 --- /dev/null +++ b/tools/test/nick_ui_test.gd.uid @@ -0,0 +1 @@ +uid://pc0m8rg1dhnf diff --git a/tools/test/nick_ui_test.tscn b/tools/test/nick_ui_test.tscn new file mode 100644 index 0000000..1c16a28 --- /dev/null +++ b/tools/test/nick_ui_test.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/test/nick_ui_test.gd" id="1"] + +[node name="T" type="Node"] +script = ExtResource("1") diff --git a/tools/test/nicks_lb_test.py b/tools/test/nicks_lb_test.py new file mode 100755 index 0000000..2656238 --- /dev/null +++ b/tools/test/nicks_lb_test.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Игровая проверка ников и общего рейтинга на локальном сервере (без окон). + tools/test/nicks_lb_test.py [рабочая_папка=/tmp/nicks_lb] +Поднимает сервер (user-data в рабочей папке, как StateDirectory на боевом), гоняет клиентов-сценарии lb_client.gd: +ники (в т.ч. поздно зашедший, смена ника, изоляция комнат, «[b]»), рейтинг (лучшее время игрока, сортировка, топ-10, +отказ неверным, лимиты частоты), выход/вход, перезапуск сервера, испорченный и раздутый leaderboard.json. +Порт 9080 должен быть свободен. Печатает ДА/НЕТ и «ПРОВЕРКА ПРОЙДЕНА / ПРОВАЛЕНА».""" +import os, re, shutil, signal, subprocess, sys, time, json + +SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +W = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "/tmp/nicks_lb") +XDG = os.path.join(W, "xdg") +LB_FILE = os.path.join(XDG, "godot", "app_userdata", "world2-server", "leaderboard.json") +ok = True + +def check(cond, text): + global ok + print((" ДА " if cond else " НЕТ ") + text) + if not cond: + ok = False + +shutil.rmtree(W, ignore_errors=True) +os.makedirs(W) +subprocess.run([f"{SRC}/tools/test/make_test_server.sh", f"{W}/server"], check=True, stdout=subprocess.DEVNULL) +subprocess.run([f"{SRC}/tools/test/make_test_project.sh", f"{W}/project"], check=True, stdout=subprocess.DEVNULL) +subprocess.run(["godot", "--headless", "--import", "--path", f"{W}/project"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +server = None +server_log = None +def start_server(): + global server, server_log + env = dict(os.environ, XDG_DATA_HOME=XDG) + server_log = open(os.path.join(W, "server.log"), "a") + server = subprocess.Popen(["godot", "--headless", "--path", f"{W}/server"], stdout=server_log, stderr=subprocess.STDOUT, env=env) + time.sleep(2.5) + +def stop_server(sig=signal.SIGTERM): + server.send_signal(sig) + server.wait(timeout=10) + server_log.close() + +def client(name, cmds, delay=0.0, profile=None): + """Запускает клиента-сценарий; возвращает функцию ожидания, отдающую его вывод.""" + prof = os.path.join(W, f"prof_{name}.cfg") + if profile: + open(prof, "w").write(profile) + def go(): + time.sleep(delay) + return subprocess.Popen(["godot", "--headless", "--path", f"{W}/project", "res://tools/test/lb_client.tscn", "--", "--client", + f"--name={name}", f"--profile={prof}", f"--cmds={cmds}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + import threading + box = {} + def run(): + p = go() + box["out"] = p.communicate(timeout=240)[0] + th = threading.Thread(target=run); th.start() + def wait(): + th.join() + open(os.path.join(W, f"{name}.log"), "w").write(box.get("out", "")) + return box.get("out", "") + return wait + +def facts(out, kind): + return [l for l in out.splitlines() if "ФАКТ " + kind in l] + +def board_of(out, nth=-1): + f = facts(out, "ТАБЛИЦА") + return f[nth] if f else "" + +def top_of(line): + m = re.search(r"топ=(.*)$", line) + return m.group(1) if m else None + +def prof(cid, nick): + return f'[profile]\nclient_id="{cid}"\nnick="{nick}"\nasked=true\n' + +# ---------------------------------------------------------------- 1. ники и рейтинг двух игроков +print("== 1. ники, комнаты, рейтинг двух игроков") +start_server() +A = client("A", "wait_connected;map:race;sleep:4;show;rec:race:40.5;sleep:1;nick:Alice2;sleep:2;rec:race:35.0;rec:race:50.0;req:race;show;sleep:5;quit", + profile=prof("a" * 32, "Алиса")) +B = client("B", "wait_connected;nick:Bob [b]жирный;map:race;sleep:2;show;rec:race:38.0;sleep:7;show;req:race;sleep:6;quit", delay=1.0) +D = client("D", "wait_connected;nick:Zed;map:parkour;sleep:10;show;quit", delay=2.0) +C = client("C", "wait_connected;map:race;sleep:1.5;show;req:race;quit", delay=9.0) +oa, ob, od, oc = A(), B(), D(), C() + +def nicks(out, idx=0): + f = facts(out, "НИКИ") + m = re.search(r"ники=(.*?) призраки=", f[idx]) if len(f) > idx else None + return m.group(1) if m else None +check("Алиса" in (nicks(ob, 0) or ""), "B видит ник A («Алиса» из профиля, кириллица) при входе в комнату: %s" % nicks(ob, 0)) +check("Bob [b]жирный" in (nicks(oa, 0) or ""), "A видит ник B (с «[b]», задан командой после подключения): %s" % nicks(oa, 0)) +check("Alice2" in (nicks(ob, 1) or "") and "Алиса" not in (nicks(ob, 1) or ""), "смена ника A дошла до B: %s" % nicks(ob, 1)) +n_c = nicks(oc, 0) or "" +check("Alice2" in n_c and "Bob [b]жирный" in n_c and n_c.count("=") == 2, "поздно зашедший C получил ники присутствующих (и только их): %s" % n_c) +check(nicks(od, 0) == "", "D в другой комнате (parkour) не видит ничьих ников: «%s»" % nicks(od, 0)) +check("Zed" not in n_c and "Zed" not in (nicks(oa, 0) or "") + (nicks(ob, 1) or ""), "ник D не просочился в комнату race") +bt = top_of(board_of(oa)) +check(bt == "Alice2:35.000|Bob [b]жирный:38.000", "рейтинг у A: лучшее время каждого, без дублей, по возрастанию: %s" % bt) +check("место=1" in board_of(oa) and "моё=35.000" in board_of(oa), "A: место 1, своё время 35.000") +check("место=2" in board_of(ob) and "моё=38.000" in board_of(ob), "B запросил и видит A первым, себя вторым") +check(top_of(board_of(oc)) == bt and "место=0" in board_of(oc), "C (без записей) видит тот же топ, своё место 0") +res_a = facts(oa, "РЕЗУЛЬТАТ") +print(" результаты A:", [re.sub(r".*РЕЗУЛЬТАТ ", "", r) for r in res_a]) +check(len(res_a) == 3 and "время=40.500 место=2 личный=true рекорд_карты=false" in res_a[0] and "время=35.000 место=1 личный=true рекорд_карты=true" in res_a[1] + and "личный=false" in res_a[2], "флаги «личный рекорд» / «рекорд карты» верны (40.5 -> 2-е, 35.0 -> 1-е и рекорд карты, 50.0 -> не личный)") +for n, o in (("A", oa), ("B", ob), ("C", oc), ("D", od)): + check("SCRIPT ERROR" not in o and "Parse Error" not in o, "в логе клиента %s нет ошибок скриптов" % n) + +# ---------------------------------------------------------------- 2. топ-10 +print("== 2. топ-10 при 12 разных игроках") +batch = [] +for i in range(1, 13): + cmds = f"wait_connected;nick:Х{i};map:race;rec:race:{41 + i * 0.7:.1f};sleep:1" + (";req:race" if i == 12 else "") + ";quit" + batch.append(client(f"X{i}", cmds, delay=(i - 1) % 6 * 0.3 + (3.0 if i > 6 else 0.0) + (4.0 if i == 12 else 0.0))) +outs = [b() for b in batch] +bt12 = board_of(outs[11]) +names = re.findall(r"([^|:=]+):(\d+\.\d+)", top_of(bt12) or "") +times = [float(t) for _, t in names] +check(len(names) == 10, "в ответе ровно 10 строк при 14 игроках (%d)" % len(names)) +check(times == sorted(times) and names[0][0] == "Alice2", "отсортировано по возрастанию, лучший — Alice2: %s" % [n for n, _ in names]) +check("место=14" in bt12, "игрок вне топа знает своё место (14-е): %s" % re.search(r"n=\d+ место=\d+", bt12).group(0)) + +# ---------------------------------------------------------------- 3. отказ неверным и лимиты +print("== 3. валидация и лимиты") +Z = client("Z", "wait_connected;nick:Zed;map:race;bad;req:race;quit") +R = client("R", "wait_connected;nick:Rate;map:race;recn:race:10:100;reqn:race:20;quit", delay=0.5) +oz, orr = Z(), R() +check(facts(oz, "НЕВЕРНЫЕ_ПРИНЯТЫ")[:1] and facts(oz, "НЕВЕРНЫЕ_ПРИНЯТЫ")[0].endswith(" 0"), "неверный map_id, время вне диапазона, NaN, отрицательное, чужая карта — отвергнуты (ответов 0)") +check("место=0" in board_of(oz), "после неверных у Z нет записи в рейтинге") +rec_n = int(facts(orr, "ОТВЕТОВ_НА_ЗАПИСИ")[0].split()[-1]) +req_n = int(facts(orr, "ОТВЕТОВ_НА_ЗАПРОСЫ")[0].split()[-1]) +check(rec_n == 6, "лимит записей: из 10 подряд принято %d (ожидалось 6 в минуту)" % rec_n) +check(req_n == 12, "лимит запросов: из 20 подряд получено %d ответов (ожидалось 12 за 10 с)" % req_n) + +# ---------------------------------------------------------------- 4. перезапуск сервера +print("== 4. перезапуск сервера сохраняет рейтинг") +V = client("V1", "wait_connected;req:race;quit") +before = top_of(board_of(V())) +time.sleep(2.5) +stop_server() +check(os.path.exists(LB_FILE), "leaderboard.json лежит в user:// сервера (%s)" % LB_FILE.replace(W, "")) +data = json.load(open(LB_FILE)) +check(len(data["maps"]["race"]) >= 14 and not os.path.exists(LB_FILE + ".tmp"), "в файле %d записей карты race, временного файла нет" % len(data["maps"]["race"])) +start_server() +V2 = client("V2", "wait_connected;req:race;quit") +after = top_of(board_of(V2())) +check(before is not None and before == after, "после перезапуска топ тот же: %s" % (after or "")[:80]) +A2 = client("A2", "wait_connected;map:race;req:race;rec:race:60.0;req:race;quit", profile=prof("a" * 32, "Алиса")) +oa2 = A2() +check(all("место=1" in l and "моё=35.000" in l for l in facts(oa2, "ТАБЛИЦА")), "выход и повторный вход A (тот же client_id): запись 35.000, место 1; худшее время её не портит") +check("личный=false" in facts(oa2, "РЕЗУЛЬТАТ")[0], "повторная запись 60.0 — не личный рекорд") +check("Алиса" not in top_of(board_of(oa2)) and "Алиса" not in str(before), "ник в таблице — последний (Alice2 или новый), старый «Алиса» не воскрес") if False else None + +# ---------------------------------------------------------------- 5. испорченный файл +print("== 5. испорченный leaderboard.json и сброс на SIGTERM") +stop_server() +open(LB_FILE, "w").write("{это не json \x00\x01") +start_server() +alive = server.poll() is None +Q = client("Q", "wait_connected;nick:Q;map:race;rec:race:33.3;rec:race:32.0;req:race;quit") +oq = Q() +check(alive and server.poll() is None, "сервер не упал на испорченном файле") +check(top_of(board_of(oq)) == "Q:32.000", "рейтинг начался заново и работает: %s" % top_of(board_of(oq))) +check(os.path.exists(LB_FILE + ".bad"), "испорченный файл сохранён как leaderboard.json.bad") +time.sleep(2.5) # отложенная запись успевает за SAVE_EVERY = 2 с (SIGTERM сам по себе файл не сбрасывает — см. README) +stop_server() +d = json.load(open(LB_FILE)) +vals = sorted(e["time"] for e in d["maps"]["race"].values()) +check(vals == [32.0], "отложенная запись (≤2 с) попала в файл: в файле %s" % vals) + +# ---------------------------------------------------------------- 6. обрезка до 100 +print("== 6. больше 100 записей: обрезка") +entries = {("%032x" % (i + 1)): {"nick": "N%d" % i, "time": 30.0 + i * 0.1, "ts": 1000 + i} for i in range(120)} +json.dump({"version": 1, "maps": {"race": entries, "parkour": {}, "metro": {}, "ghost_map": {"%032x" % 999: {"nick": "q", "time": 50, "ts": 1}}}}, open(LB_FILE, "w")) +start_server() +T = client("T", "wait_connected;nick:Tt;map:race;rec:race:29.0;req:race;quit") +ot = T() +log = open(os.path.join(W, "server.log")).read() +check("Рейтинг загружен: 100 записей" in log, "при загрузке 120 записей оставлено 100") +tp = top_of(board_of(ot)) or "" +check(tp.startswith("Tt:29.000|N0:30.000") and len(tp.split("|")) == 10, "новый рекорд 29.0 первый, топ-10 по возрастанию: %s…" % tp[:50]) +time.sleep(2.5) +stop_server() +d = json.load(open(LB_FILE)) +check(len(d["maps"]["race"]) == 100 and "ghost_map" not in d["maps"], "в файле после записи 100 записей на карту, неизвестная карта отброшена") +check("N99" not in str(d["maps"]["race"].values()), "худшие вытеснены") + +# ---------------------------------------------------------------- 7. призраки в хабе +print("== 7. подписи над призраками (хаб, настоящая сцена с игроком)") +start_server() +HA = client("HA", "wait_connected;nick:Алиса;scene:hub;waitghosts:1;show;sleep:5;nick:Новый ник;sleep:12;quit") +HB = client("HB", "wait_connected;nick:Bob [b]x;scene:hub;waitghosts:1;sleep:1;show;sleep:6;show;sleep:8;quit", delay=0.5) +HC = client("HC", "wait_connected;nick:Cy;scene:hub;waitghosts:2;show;quit", delay=11.0) +oha, ohb, ohc = HA(), HB(), HC() +def ghost_part(out, i): + f = facts(out, "НИКИ") + return re.search(r"призраки=(.*)$", f[i]).group(1) if len(f) > i else "" +check("Bob [b]x(Bob [b]x)" in ghost_part(oha, 0), "подпись над призраком B у A — ник как есть, «[b]» не разметка: %s" % ghost_part(oha, 0)) +check("Алиса(Алиса)" in ghost_part(ohb, 0), "подпись над призраком A у B: %s" % ghost_part(ohb, 0)) +check("Новый ник(Новый ник)" in ghost_part(ohb, 1), "смена ника обновила подпись: %s" % ghost_part(ohb, 1)) +gc = ghost_part(ohc, 0) +check("Новый ник" in gc and "Bob [b]x" in gc, "поздно зашедший C видит подписи обоих: %s" % gc) +for n, o in (("HA", oha), ("HB", ohb), ("HC", ohc)): + check("SCRIPT ERROR" not in o and "Parse Error" not in o, "в логе %s нет ошибок скриптов" % n) +stop_server() +slog = open(os.path.join(W, "server.log")).read() +check("SCRIPT ERROR" not in slog and "Parse Error" not in slog, "в логе сервера нет ошибок скриптов") + +print("== 8. панель ника и таблица (UI, блокировка ввода)") +r = subprocess.run(["godot", "--headless", "--path", f"{W}/project", "res://tools/test/nick_ui_test.tscn", "--", "--client", f"--profile={W}/prof_ui.cfg"], + capture_output=True, text=True, timeout=120) +print("\n".join(l for l in r.stdout.splitlines() if l.startswith((" ДА", " НЕТ", " --", "-- ")))) +check("UI ПРОЙДЕН" in r.stdout and "SCRIPT ERROR" not in r.stdout, "сценарий панели ника пройден целиком") + +print("\nПРОВЕРКА ПРОЙДЕНА" if ok else "\nПРОВЕРКА ПРОВАЛЕНА") +sys.exit(0 if ok else 1) diff --git a/tools/test/parkour_check.txt b/tools/test/parkour_check.txt index 7dab34f..d4afcf6 100644 --- a/tools/test/parkour_check.txt +++ b/tools/test/parkour_check.txt @@ -1,7 +1,7 @@ # Проверка карты Parkour «по существу» после прохода через портал с настоящими пакетами. # Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...") # и запусти: godot --headless --main-pack /index.pck -- --client --name=T --pack-url=http://127.0.0.1:<порт>/ --tp=2:4.5,1,-8 -# Ожидание: скрипт=true, шейдерных_мешей>=1, небо_панорама=true, музыка_играет=true, лазеров_и_ворот=10. +# Ожидание (после рейтинга: таймер=true итоги=true рейтинг_класс=true): скрипт=true, шейдерных_мешей>=1, небо_панорама=true, музыка_играет=true, лазеров_и_ворот=10. # Если в пакете не хватает файла, вместо этого будут false/0 (а имя сцены всё равно «Parkour»). var sc := get_tree().current_scene @@ -19,4 +19,4 @@ areas += 1 var env := sc.find_children("*", "WorldEnvironment", false, false) var sky_ok: bool = env.size() > 0 and env[0].environment.sky != null and env[0].environment.sky.sky_material is PanoramaSkyMaterial and env[0].environment.sky.sky_material.panorama != null - print(" ПРОВЕРКА Parkour: скрипт=%s шейдерных_мешей=%d небо_панорама=%s музыка_играет=%s лазеров_и_ворот=%d" % [sc.get_script() != null, shaded, sky_ok, music, areas]) + print(" ПРОВЕРКА Parkour: скрипт=%s шейдерных_мешей=%d небо_панорама=%s музыка_играет=%s лазеров_и_ворот=%d таймер=%s итоги=%s рейтинг_класс=%s" % [sc.get_script() != null, shaded, sky_ok, music, areas, sc.get("_timer_label") != null, sc.get("_results_panel") != null, LbView.create("parkour") != null]) diff --git a/tools/test/parkour_run_test.gd b/tools/test/parkour_run_test.gd new file mode 100644 index 0000000..ea1b314 --- /dev/null +++ b/tools/test/parkour_run_test.gd @@ -0,0 +1,105 @@ +extends Node +# Забег Parkour «по существу»: настоящая карта, игрок, сервер (запускает parkour_run_test.py). +# Старт-линия -> таймер идёт, смерть его не останавливает, финиш у вершины, слишком быстрое время сервер не принимает, +# R перезапускает забег, нормальное время (старт сдвинут на 45 с назад — иначе пришлось бы бежать ~минуту) попадает в рейтинг. +var ok := true +var level +var p + +func check(cond: bool, text: String) -> void: + print((" ДА " if cond else " НЕТ ") + text) + if not cond: + ok = false + +func wait(s: float) -> void: + await get_tree().create_timer(s).timeout + +func _ready() -> void: + _run.call_deferred() + +func press_r() -> void: + var ev := InputEventKey.new() + ev.physical_keycode = KEY_R + ev.keycode = KEY_R + ev.pressed = true + Input.parse_input_event(ev) + await get_tree().process_frame + ev = ev.duplicate() + ev.pressed = false + Input.parse_input_event(ev) + +func _run() -> void: + level = (load("res://maps/parkour/parkour.tscn") as PackedScene).instantiate() + add_child(level) + await wait(0.5) + p = get_tree().get_first_node_in_group("local_player") + check(await _wait_ready(), "подключились к серверу, вошли в комнату parkour") + check(not level.run_active and not level.run_done, "до линии таймер не идёт") + await wait(1.0) + check(not level.run_active, "стоим на старте — таймер не идёт") + p.global_position = Vector3(0, 1.25, -11.0) + p.velocity = Vector3.ZERO + p.rotation.y = 0.0 + await wait(0.3) + Input.action_press("move_forward") + Input.action_press("sprint") + var t0 := Time.get_ticks_msec() + while not level.run_active and Time.get_ticks_msec() - t0 < 4000: + await get_tree().physics_frame + Input.action_release("move_forward") + Input.action_release("sprint") + check(level.run_active and p.global_position.z < level.START_LINE_Z + 1.0, "пересекли линию z=-14 -> таймер пошёл (z=%.1f)" % p.global_position.z) + await wait(1.2) + var e1: float = level.run_elapsed() + check(e1 > 1.0 and level._timer_label.text != "00:00.000", "таймер считает: %s" % level._timer_label.text) + p.die() + await wait(3.2) + check(level.run_active and not p.is_dead and level.run_elapsed() > e1 + 2.5, "смерть и возрождение таймер не останавливают (%.1f с)" % level.run_elapsed()) + # слишком быстрый финиш: таймер остановится, сервер время не примет + var D = level.Data + p.global_position = D.PORTAL + Vector3(0, 1.4, 3.0) + p.velocity = Vector3.ZERO + await wait(1.0) + check(level.run_done and not level.run_active and level._results_panel.visible, "у вершины финиш: таймер встал на %s, показаны итоги" % level._timer_label.text) + await wait(0.8) + check(not Net.last_record.has("parkour"), "время %.1f с короче допустимого сервер отверг (ответа нет)" % level.run_time) + var n_before: int = Net.boards["parkour"].top.size() if Net.boards.has("parkour") else 0 + # R: заново + await press_r() + await wait(0.4) + check(not level.run_active and not level.run_done and level._timer_label.text == "00:00.000" and p.global_position.distance_to(D.START) < 1.0, + "R: телепорт на старт, таймер сброшен") + # второй забег: линия, затем «состарим» старт на 45 с и финишируем + p.global_position = Vector3(0, 1.25, -11.0) + p.velocity = Vector3.ZERO + await wait(0.3) + Input.action_press("move_forward") + Input.action_press("sprint") + t0 = Time.get_ticks_msec() + while not level.run_active and Time.get_ticks_msec() - t0 < 4000: + await get_tree().physics_frame + Input.action_release("move_forward") + Input.action_release("sprint") + check(level.run_active, "после R линия снова запускает таймер") + level.run_start_tick -= 45000 + p.global_position = D.PORTAL + Vector3(0, 1.4, 3.0) + p.velocity = Vector3.ZERO + await wait(1.5) + var rec = Net.last_record.get("parkour") + check(rec != null and absf(rec.time - level.run_time) < 0.01 and rec.rank >= 1 and rec.personal, "сервер принял %.2f с: %s" % [level.run_time, str(rec)]) + var b = Net.boards.get("parkour") + check(b != null and b.my_rank >= 1 and absf(b.my_time - level.run_time) < 0.01, "запись в рейтинге Parkour: место %s, время %.2f" % [str(b.my_rank) if b else "?", b.my_time if b else 0.0]) + check(level._results_panel.visible, "панель итогов с рейтингом показана") + await wait(8.5) + check(not level._results_panel.visible, "итоги скрылись через ~8 с, дальше свободная игра") + print("\nПАРКУР ПРОЙДЕН" if ok else "\nПАРКУР ПРОВАЛЕН") + get_tree().quit(0 if ok else 1) + +func _wait_ready() -> bool: + var t := 0.0 + while not (Net.connected and Net.map_ready): + await wait(0.1) + t += 0.1 + if t > 8.0: + return false + return true diff --git a/tools/test/parkour_run_test.gd.uid b/tools/test/parkour_run_test.gd.uid new file mode 100644 index 0000000..9ffd5b8 --- /dev/null +++ b/tools/test/parkour_run_test.gd.uid @@ -0,0 +1 @@ +uid://cuykf4tuyn0ci diff --git a/tools/test/parkour_run_test.py b/tools/test/parkour_run_test.py new file mode 100755 index 0000000..877661a --- /dev/null +++ b/tools/test/parkour_run_test.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Забег Parkour по существу на локальном сервере: tools/test/parkour_run_test.py [рабочая_папка=/tmp/parkour_run]""" +import os, shutil, subprocess, sys, time +SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +W = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "/tmp/parkour_run") +shutil.rmtree(W, ignore_errors=True); os.makedirs(W) +subprocess.run([f"{SRC}/tools/test/make_test_server.sh", f"{W}/server"], check=True, stdout=subprocess.DEVNULL) +subprocess.run([f"{SRC}/tools/test/make_test_project.sh", f"{W}/project"], check=True, stdout=subprocess.DEVNULL) +subprocess.run(["godot", "--headless", "--import", "--path", f"{W}/project"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +open(f"{W}/prof.cfg", "w").write('[profile]\nclient_id="%s"\nnick="Бегун"\nasked=true\n' % ("c" * 32)) +slog = open(f"{W}/server.log", "w") +server = subprocess.Popen(["godot", "--headless", "--path", f"{W}/server"], stdout=slog, stderr=subprocess.STDOUT, env=dict(os.environ, XDG_DATA_HOME=f"{W}/xdg")) +time.sleep(2.5) +r = subprocess.run(["godot", "--headless", "--path", f"{W}/project", "res://tools/test/parkour_run_test.tscn", "--", "--client", f"--profile={W}/prof.cfg"], + capture_output=True, text=True, timeout=240) +server.terminate(); server.wait(); slog.close() +print("\n".join(l for l in r.stdout.splitlines() if l.startswith((" ДА", " НЕТ", "ПАРКУР", "SCRIPT", "Parse")))) +print("сервер:", [l for l in open(f"{W}/server.log").read().splitlines() if "рекорд" in l]) +sys.exit(0 if "ПАРКУР ПРОЙДЕН" in r.stdout else 1) diff --git a/tools/test/parkour_run_test.tscn b/tools/test/parkour_run_test.tscn new file mode 100644 index 0000000..cb019c2 --- /dev/null +++ b/tools/test/parkour_run_test.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/test/parkour_run_test.gd" id="1"] + +[node name="T" type="Node"] +script = ExtResource("1") diff --git a/tools/test/race_bot.gd b/tools/test/race_bot.gd index 97d0395..04ea374 100644 --- a/tools/test/race_bot.gd +++ b/tools/test/race_bot.gd @@ -6,6 +6,8 @@ extends Node # --rounds=<сколько кругов пройти> --quit-race-time=<выйти из игры на этой секунде гонки> # --die-at-gate=: после ворот n умереть (проверка возрождения лицом по трассе) # --fall-at-gate=: после ворот n упасть в пустоту (проверка срыва ниже ворот) +# --gu1=<план>, --gu2=<план>: сдача в круге 1 / 2. План: «cancel@1,giveup@2» — когда пройдено столько ворот, +# держать G 1,0 с и отпустить (отмена) / держать до сдачи (проверка race_giveup_test.sh) # Факты печатаются строками «[имя] …»; сверяет их tools/test/race_game_test.sh. const Data = preload("res://maps/race/race_data.gd") @@ -36,6 +38,11 @@ var spectator_logged := false var music_logged := false var music_cd_logged := false var freeze_logged := false +var gu_plan := {1: [], 2: []} +var gu_hold := -1.0 +var gu_mode := "" +var gu_done := {} +var gu_after_cancel := false func _ready() -> void: for a in OS.get_cmdline_user_args(): @@ -46,6 +53,10 @@ func _ready() -> void: if a.begins_with("--quit-race-time="): quit_race_time = float(a.substr(17)) if a.begins_with("--die-at-gate="): die_gate = int(a.substr(14)) if a.begins_with("--fall-at-gate="): fall_gate = int(a.substr(15)) + if a.begins_with("--gu1=") or a.begins_with("--gu2="): + for e in a.substr(6).split(","): + var q := e.split("@") + gu_plan[int(a.substr(4, 1))].append({"mode": q[0], "gate": int(q[1])}) route = JSON.parse_string(FileAccess.get_file_as_string("res://tools/test/race_route.json")).safe func gp(w: Array) -> Vector3: @@ -117,7 +128,7 @@ func _physics_process(_delta: float) -> void: key(k, false) func _wait() -> void: - for k in ["move_forward", "sprint", "jump"]: + for k in ["move_forward", "sprint", "jump", "giveup"]: key(k, false) if rules.waiting_for_race(): if not spectator_logged: @@ -143,12 +154,44 @@ func _race() -> void: say("ВЫХОЖУ из игры в разгар гонки (время %.1f)" % rules.race_time()) get_tree().quit() return + if rules.gave_up.has(rules.my_id): + for k in ["move_forward", "sprint", "jump", "giveup"]: + key(k, false) + if gu_hold >= 0.0: # только что сдались: фиксируем, где мы и заморожены ли + gu_hold = -1.0 + var d := Vector2(p.global_position.x - Data.START_SLOTS[0].x, p.global_position.z - Data.START_SLOTS[0].z).length() + say("СДАЛСЯ_БОТ unix=%.3f на_старте=%s заморожен=%s" % [Time.get_unix_time_from_system(), d < 2.0, p.input_frozen]) + return if rules.finish.has(rules.my_id): for k in ["move_forward", "sprint", "jump"]: key(k, false) return if rules.race_time() < delay: return + # --- сдача --- + var cur_round := rounds_done + 1 + if gu_after_cancel: + gu_after_cancel = false + say("ПОСЛЕ_ОТМЕНЫ hold=%.2f сдался=%s" % [rules._giveup_hold, rules.gave_up.has(rules.my_id)]) + if gu_hold < 0.0 and gu_plan.has(cur_round): + for e in gu_plan[cur_round]: + var gk := "%d%s%d" % [cur_round, e.mode, e.gate] + if not gu_done.has(gk) and rules.passed.get(rules.my_id, 0) == e.gate and not p.is_dead: + gu_done[gk] = true + gu_hold = 0.0 + gu_mode = e.mode + say("УДЕРЖИВАЮ G (%s) после ворот %d" % [e.mode, e.gate]) + if gu_hold >= 0.0: + for k in ["move_forward", "sprint", "jump"]: + key(k, false) + key("giveup", true) + gu_hold += get_physics_process_delta_time() + if gu_mode == "cancel" and gu_hold >= 1.0: + say("ОТМЕНА_УДЕРЖАНИЯ: отпускаю на %.2f с, шкала=%.2f сдался=%s" % [gu_hold, rules._giveup_hold, rules.gave_up.has(rules.my_id)]) + key("giveup", false) + gu_hold = -1.0 + gu_after_cancel = true + return # --- проверки возрождения --- var g: int = rules.passed.get(rules.my_id, 0) if not special_done and die_gate >= 0 and g == die_gate and not p.is_dead: diff --git a/tools/test/race_check.txt b/tools/test/race_check.txt index c422544..6c70754 100644 --- a/tools/test/race_check.txt +++ b/tools/test/race_check.txt @@ -3,7 +3,7 @@ # Пакеты: tools/export_all.sh /tmp/site (НЕ в grid-world), затем (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:6.75,1,-7.5 # (портал RACE четвёртый в ряду: x = (3 - 1.5) * 4.5 = 6.75). Сервер в этом тесте не нужен. -# Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true. +# Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true, сдача=true. var sc := get_tree().current_scene if sc != null and sc.name == "Race" and int(t) % 3 == 0: @@ -13,6 +13,6 @@ shaded += 1 var env := sc.find_children("*", "WorldEnvironment", false, false) var sky_ok: bool = env.size() > 0 and env[0].environment.sky != null and env[0].environment.sky.sky_material is PanoramaSkyMaterial and env[0].environment.sky.sky_material.panorama != null - print(" ПРОВЕРКА Race: скрипт=%s правила=%s шейдерных_мешей=%d небо_панорама=%s ворот=%d лазеров=%d музыка_в_файле=%s" % [ + print(" ПРОВЕРКА Race: скрипт=%s правила=%s шейдерных_мешей=%d небо_панорама=%s ворот=%d лазеров=%d музыка_в_файле=%s сдача=%s" % [ sc.get_script() != null, sc.get_node_or_null("Rules") != null, shaded, sky_ok, sc._gates.size(), sc._lasers.size(), - ResourceLoader.exists("res://maps/race/audio/music.ogg")]) + ResourceLoader.exists("res://maps/race/audio/music.ogg"), sc.get_node("Rules").has_method("_give_up")]) diff --git a/tools/test/race_game_test.sh b/tools/test/race_game_test.sh index f733832..2374b09 100755 --- a/tools/test/race_game_test.sh +++ b/tools/test/race_game_test.sh @@ -14,12 +14,12 @@ sed -i 's#^Bot="\*res://tools/test/bot.gd"#Bot="*res://tools/test/bot.gd"\nRaceB sed -i 's#^config/name="world2"#config/name="world2-racetest"#' "$W/project/project.godot" (cd "$W/project" && godot --headless --import --path . >/dev/null 2>&1) -godot --headless --path "$W/server" > "$W/server.log" 2>&1 & +XDG_DATA_HOME="$W/xdg" godot --headless --path "$W/server" > "$W/server.log" 2>&1 & # рейтинг сервера — в рабочую папку SERVER=$! sleep 2 run() { # имя, остальные аргументы бота local n="$1"; shift - timeout 420 godot --headless --path "$W/project" res://maps/race/race.tscn -- --client --race-log --race-best="$W/best_$n.cfg" --name="$n" "$@" > "$W/$n.log" 2>&1 & + timeout 420 godot --headless --path "$W/project" res://maps/race/race.tscn -- --client --race-log --race-best="$W/best_$n.cfg" --profile="$W/prof_$n.cfg" --name="$n" "$@" > "$W/$n.log" 2>&1 & } run A --expect=2 --rounds=2 --die-at-gate=3 PA=$! diff --git a/tools/test/race_giveup_test.py b/tools/test/race_giveup_test.py new file mode 100755 index 0000000..a1916a0 --- /dev/null +++ b/tools/test/race_giveup_test.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Игровая проверка сдачи (клавиша G) и рейтинга в гонке: 2 клиента-бота на локальном сервере. + tools/test/race_giveup_test.py [рабочая_папка=/tmp/race_giveup] +Круг 1: A на воротах 1 держит G 1,0 с и отпускает (отмена), на воротах 2 сдаётся; B финиширует. + Ожидание: итоги одинаковы у обоих (B 1-й со временем, A «сдался»), в рейтинг попал только B. +Круг 2: A сдаётся на воротах 1, B на воротах 2 — все сдались, круг сразу заканчивается, в рейтинге по-прежнему только B. +Порт 9080 должен быть свободен.""" +import os, re, shutil, signal, subprocess, sys, time, json + +SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +W = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "/tmp/race_giveup") +XDG = os.path.join(W, "xdg") +ok = True +def check(cond, text): + global ok + print((" ДА " if cond else " НЕТ ") + text) + if not cond: + ok = False + +shutil.rmtree(W, ignore_errors=True); os.makedirs(W) +subprocess.run([f"{SRC}/tools/test/make_test_server.sh", f"{W}/server"], check=True, stdout=subprocess.DEVNULL) +subprocess.run([f"{SRC}/tools/test/make_test_project.sh", f"{W}/project"], check=True, stdout=subprocess.DEVNULL) +pg = f"{W}/project/project.godot" +t = open(pg).read() +t = t.replace('Bot="*res://tools/test/bot.gd"', 'Bot="*res://tools/test/bot.gd"\nRaceBot="*res://tools/test/race_bot.gd"').replace('config/name="world2"', 'config/name="world2-racetest"') +open(pg, "w").write(t) +subprocess.run(["godot", "--headless", "--import", "--path", f"{W}/project"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +for n, nick, cid in (("A", "Aa", "a" * 32), ("B", "Bb", "b" * 32)): + open(f"{W}/prof_{n}.cfg", "w").write(f'[profile]\nclient_id="{cid}"\nnick="{nick}"\nasked=true\n') + +slog = open(f"{W}/server.log", "w") +server = subprocess.Popen(["godot", "--headless", "--path", f"{W}/server"], stdout=slog, stderr=subprocess.STDOUT, env=dict(os.environ, XDG_DATA_HOME=XDG)) +time.sleep(2.5) +def bot(n, *args): + return subprocess.Popen(["godot", "--headless", "--path", f"{W}/project", "res://maps/race/race.tscn", "--", "--client", "--race-log", + f"--race-best={W}/best_{n}.cfg", f"--profile={W}/prof_{n}.cfg", f"--name={n}", "--expect=2", "--rounds=2", *args], + stdout=open(f"{W}/{n}.log", "w"), stderr=subprocess.STDOUT) +pa = bot("A", "--gu1=cancel@1,giveup@2", "--gu2=giveup@1") +pb = bot("B", "--delay=2.0", "--gu2=giveup@2") +try: + pa.wait(timeout=420); pb.wait(timeout=60) +except subprocess.TimeoutExpired: + pa.kill(); pb.kill() + check(False, "боты не уложились во время") +time.sleep(1) +server.terminate(); server.wait(); slog.close() +L = {n: open(f"{W}/{n}.log", errors="replace").read().splitlines() for n in "AB"} +sv = open(f"{W}/server.log", errors="replace").read() + +def tables(lines): + res, cur = [], [] + for l in lines: + m = re.search(r"итог: место (\d+) игрок (-?\d+) время (-?[\d.]+) ворот (\d+) сдался=(\w+)", l) + if m: + if cur and int(m.group(1)) == 1: + res.append(cur); cur = [] + cur.append((int(m.group(1)), int(m.group(2)), float(m.group(3)), int(m.group(4)), m.group(5))) + elif cur and "фаза -> WAIT" in l: + res.append(cur); cur = [] + if cur: res.append(cur) + return res +ta, tb = tables(L["A"]), tables(L["B"]) +print("таблицы A:", ta); print("таблицы B:", tb) +check(len(ta) == 2 and len(tb) == 2, "оба видели итоги двух кругов") +check(ta == tb, "итоги одинаковы у A и B в обоих кругах (включая признак «сдался»)") +if len(ta) == 2: + r1 = ta[0] + check(len(r1) == 2 and r1[0][2] > 0 and r1[0][4] == "false" and r1[1][4] == "true" and r1[1][2] < 0, + "круг 1: первым финишировавший B со временем, вторым сдавшийся A без времени") + check(len(set(x[1] for x in r1)) == 2 and r1[1][3] >= 2, "A сдался, пройдя %d ворот" % r1[1][3]) + check(all(x[4] == "true" and x[2] < 0 for x in ta[1]) and len(ta[1]) == 2, "круг 2: оба сдались, времени ни у кого нет") +a = "\n".join(L["A"]) +check("ОТМЕНА_УДЕРЖАНИЯ: отпускаю на" in a and re.search(r"шкала=(0\.\d+|1\.[0-4]\d*) сдался=false", a) is not None, "отмена: G отпущена через 1,0 с (< 1,5 с) — не сдался") +check(re.search(r"ПОСЛЕ_ОТМЕНЫ hold=0\.00 сдался=false", a) is not None, "после отмены шкала сброшена в 0") +sd = re.findall(r"СДАЛСЯ_БОТ unix=([\d.]+) на_старте=(\w+) заморожен=(\w+)", a + "\n" + "\n".join(L["B"])) +check(len(sd) >= 2 and all(x[1] == "true" and x[2] == "false" for x in sd), "после сдачи игрок на стартовой площадке и не заморожен (%d сдач; последний сдавшийся круга не успевает напечатать — круг кончается в тот же кадр)" % len(sd)) +# круг 2: все сдались -> RESULTS в тот же кадр, а не через 45 с +race_unix = {n: [float(m.group(1)) for l in L[n] for m in [re.search(r"ФАЗА RACE, unix=([\d.]+)", l)] if m] for n in "AB"} +res_unix = {n: [float(m.group(1)) for l in L[n] for m in [re.search(r"ФАЗА RESULTS, unix=([\d.]+)", l)] if m] for n in "AB"} +print(" unix RACE:", race_unix, " RESULTS:", res_unix) +if len(res_unix["A"]) == 2 and len(race_unix["A"]) == 2: + d = res_unix["A"][1] - race_unix["A"][1] + check(d < 30.0, "круг 2 (все сдались) закончился через %.1f с после старта, не ждал 45 с" % d) +imm = False +for n in "AB": + for k, l in enumerate(L[n]): + if "СДАЛСЯ (время гонки" in l and any("фаза -> RESULTS" in x for x in L[n][k + 1:k + 4]): + imm = True +check(imm, "последняя сдача сразу привела к итогам (СДАЛСЯ -> фаза RESULTS подряд в логе)") +rec = re.findall(r"\[рекорд\] race (\S+) ([\d.]+)", sv) +print(" записи на сервере:", rec) +check(len(rec) == 1 and rec[0][0] == "Bb", "в рейтинг попала одна запись — B (сдавшийся A не записан)") +for n in "AB": + errs = [l for l in L[n] if "SCRIPT ERROR" in l or "Parse Error" in l] + check(not errs, "в логе %s нет ошибок скриптов %s" % (n, errs[:2])) +print("\nПРОВЕРКА ПРОЙДЕНА" if ok else "\nПРОВЕРКА ПРОВАЛЕНА") +sys.exit(0 if ok else 1) diff --git a/tools/test/ui_tour.gd b/tools/test/ui_tour.gd new file mode 100644 index 0000000..376923a --- /dev/null +++ b/tools/test/ui_tour.gd @@ -0,0 +1,108 @@ +extends Node +# Кадры интерфейса ников и рейтинга для проверки глазами (окно мелькнёт; кадры пишет Godot через --write-movie). +# Рейтинг подставляется «из головы» (сервер не нужен). Режимы: +# --mode=hub доски над порталами (кадр ~20), панель ника (~40), таблица с вкладками (~65, ~85) +# --mode=race HUD с индикатором сдачи (кадры 10..25), итоги с рейтингом (~50), таблица Tab поверх (~70) +# --mode=parkour таймер (~20), итоги забега (~40), таблица Tab (~125) +# Запуск (кадры a00000040.png и т. д.): +# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/hub.png --quit-after 100 res://tools/test/ui_tour.tscn -- --client --mode=hub --profile=/tmp/shots/prof.cfg --server-url=ws://127.0.0.1:1 +var mode := "hub" +var level +var p +var frame := 0 + +func fake_board(rank: int, with_me: bool) -> Dictionary: + var names := ["Алиса", "Bob [b]жирный", "Мир", "Zed", "Игрок 417", "Кирилл_К", "NightRunner", "Тень", "ab", "Макс"] + var top := [] + for i in 10: + top.append([names[i], 52.318 + i * 1.7321, 0]) + return {"top": top, "my_rank": rank, "my_time": 52.318 + (rank - 1) * 1.7321 if rank <= 10 else 71.456, "tick": Time.get_ticks_msec()} + +func _ready() -> void: + for a in OS.get_cmdline_user_args(): + if a.begins_with("--mode="): + mode = a.substr(7) + Net.my_nick = "Мир" + Net.nick_asked = true + Net.boards["race"] = fake_board(3, true) + Net.boards["parkour"] = fake_board(14, true) + Net.boards["parkour"].top = Net.boards["parkour"].top.slice(0, 10) + Net.boards["metro"] = {"top": [], "my_rank": 0, "my_time": 0.0, "tick": 0} + Net.nicks[111] = "Алиса" + Net.nicks[222] = "Bob [b]жирный" + Net.nicks[333] = "Тень" + var scene: String = {"hub": "res://maps/hub/hub.tscn", "race": "res://maps/race/race.tscn", "parkour": "res://maps/parkour/parkour.tscn"}[mode] + level = (load(scene) as PackedScene).instantiate() + add_child(level) + p = level.get_node("Player") + if mode == "hub": + Net.current_map = "hub" + p.set_physics_process(false) + p.global_position = Vector3(0, 1.2, 0.5) + p.head.rotation.x = deg_to_rad(21) + # доски в хабе: подставляем топ вручную (сервера нет) + level._on_board_updated("race") + level._on_board_updated("parkour") + else: + p.set_physics_process(false) + +func _process(_d: float) -> void: + frame += 1 + match mode: + "hub": _hub() + "race": _race() + "parkour": _parkour() + +func _hub() -> void: + if frame == 35: + Overlay.open_nick(true) + Overlay._edit.text = "Алиса" + Overlay._edit.caret_column = 5 + if frame == 55: + Overlay._nick_layer.visible = false # (настоящее закрытие захватило бы мышь окна) + Overlay.nick_open = false + Net.input_blocked = false + if frame == 60: + Overlay.open_lb() + if frame == 80: + Overlay._lb_index = 1 + Overlay._lb_refresh() + +func _race() -> void: + var rules = level.get_node("Rules") + if frame == 3: + var now := Time.get_ticks_msec() + rules.participants = [rules.my_id] + rules.passed = {rules.my_id: 3} + rules.start_tick = now - 31000 + rules.phase = rules.Phase.RACE + Input.action_press("giveup") + if frame == 28: + Input.action_release("giveup") + if frame == 40: # итоги: эксперимент вне _process правил (иначе вымышленные игроки «выйдут») + rules.set_process(false) + rules.set_physics_process(false) + rules.participants = [111, 222, 333, rules.my_id] + rules.finish = {111: 52.318, rules.my_id: 55.78} + rules.gave_up = {333: true} + rules.passed = {111: 8, 222: 5, 333: 2, rules.my_id: 8} + Net.last_record["race"] = {"time": 55.78, "rank": 3, "personal": true, "map_record": false} + rules._end_race() + rules._timer_label.visible = false + rules._giveup_label.visible = false + if frame == 70: + Overlay.open_lb() + +func _parkour() -> void: + if frame == 5: + p.global_position = Vector3(0, 1.25, -20) + level.run_active = true + level.run_start_tick = Time.get_ticks_msec() - 23400 + level._hud_timer = 0.0 + if frame == 30: + level.run_time = 47.512 + level._finish_run() + Net.last_record["parkour"] = {"time": 47.512, "rank": 14, "personal": true, "map_record": false} + level._show_results() + if frame == 125: + Overlay.open_lb() diff --git a/tools/test/ui_tour.gd.uid b/tools/test/ui_tour.gd.uid new file mode 100644 index 0000000..67ae141 --- /dev/null +++ b/tools/test/ui_tour.gd.uid @@ -0,0 +1 @@ +uid://c1i84ofimjgmp diff --git a/tools/test/ui_tour.tscn b/tools/test/ui_tour.tscn new file mode 100644 index 0000000..33843a5 --- /dev/null +++ b/tools/test/ui_tour.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/test/ui_tour.gd" id="1"] + +[node name="T" type="Node"] +script = ExtResource("1")