Compare commits
12
Commits
fd7523c8fb
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d347d132b | ||
|
|
0ffba7311f | ||
|
|
5d8b2165e3 | ||
|
|
3d017ffdca | ||
|
|
86a1821d27 | ||
|
|
c3e0ae40b2 | ||
|
|
b58c30a525 | ||
|
|
054b96e07c | ||
|
|
db437afd7a | ||
|
|
8c1bbe52b0 | ||
|
|
666bebfb60 | ||
|
|
d38152ea97 |
@@ -1,5 +1,11 @@
|
||||
# My World
|
||||
|
||||
# Кароче это пишу я, владелец карты
|
||||
1. Управление геймпадом
|
||||
2. Новую карту гонки, стиль тот же киберпанк, надо чтобы было длиньше первой, и упор шел на маневренность и скорость
|
||||
3. Чтобы на Tab показывался список игроков онлайн
|
||||
|
||||
|
||||
Мультиплеерная игра от первого лица в браузере (Godot 4.7.2, Web). Игроки собираются в стартовой
|
||||
комнате-хабе, заходят через порталы на разные карты и убегают там от преследователей — камня и Верити.
|
||||
Паркур-управление: спринт, рывок, подкат, присед, бег по стенам, подтягивание на уступы.
|
||||
@@ -15,7 +21,8 @@
|
||||
|
||||
| | Что |
|
||||
|---|---|
|
||||
| ✅ | Хаб с порталами; карты **Песочница**, **Backrooms**, **Parkour** и **Race**; выход из карт обратно в хаб |
|
||||
| ✅ | Хаб с порталами; карты **Песочница**, **Backrooms**, **Parkour**, **Race** и **Race 2**; выход из карт обратно в хаб |
|
||||
| 🔶 | **Race 2 «Верхние ярусы»** — вторая гонка (крыши и мосты над городом, бег по фасадам, лазерные мосты, спуск, башня; три пути на каждом участке; вентиляторы и ускоряющие полосы). **Фаза 1 готова** (раскладка, геометрия, проходимость ботами, правила); фаза 2 (чистовое запекание, своё небо и музыка) ждёт одобрения владельца — раздел «Карта Race 2» |
|
||||
| ✅ | **Race** — гонка на ногах до 8 игроков (отсчёт, ворота по порядку, места, итоги, рекорд, песня на старте); **Parkour** — подъём по мегабашне с чекпоинтами, лазерами, дождём и песней; обе карты — мрачная киберпанк-антиутопия, свет запечён в Blender (см. «Карты и порталы», «Карта Race», «Заметки для будущего меня») |
|
||||
| ✅ | Вкладка в браузере занимает **~300 МБ** (замер владельца, окт 2026) |
|
||||
| ✅ | Карты качаются отдельными пакетами `.pck` при первом входе в портал (экран загрузки с прогрессом) |
|
||||
@@ -25,9 +32,10 @@
|
||||
| ✅ | Паркур: спринт, рывок, подкат, **присед (C)**, бег по стенам, прыжок от стены, двойной прыжок, подтягивание на уступ, зум (ЛКМ) |
|
||||
| ✅ | Управление **не зависит от раскладки** (русская и любая другая): действия `InputMap` на физические клавиши |
|
||||
| ✅ | Деплой: CI из `grid-world` на сайт; скрипты экспорта, dev-сервера, тестов без окна, выкладки сервера |
|
||||
| ❌ | Телефон: **не планируется** (решение владельца, окт 2026). Сенсорного управления нет, мобильных текстур нет |
|
||||
| ❌ | Ники и текстовый чат — **запланированы**, не сделаны (подробный план в «Дорожная карта → Ближайшее») |
|
||||
| ❌ | Настроек (чувствительность мыши, громкость), меню паузы |
|
||||
| ✅ | **Геймпад Xbox** (раскладка под паркур и гонки, обзор правым стиком), **меню паузы** по Esc/Start (выход в хаб, настройки, справка), **настройки** (мышь, стик, FOV, громкость, инверсия) — разделы «Управление», «Меню паузы и настройки». В браузере с живым геймпадом не проверялось (только тест без окна) |
|
||||
| ✅ | **Ники** (панель по N, подпись над игроками, в итогах гонки) и **общий рейтинг** лучших времён на сервере (топ-10 на Race/Parkour, таблица по Tab, доски в хабе, таймер и итоги Parkour, сдача в гонке на G) — раздел «Ники и рейтинг» |
|
||||
| ❌ | Текстовый чат — **запланирован**, не сделан (план в «Дорожная карта → Ближайшее») |
|
||||
| ✅ | Хаб увеличен (48×32×10 м) и оформлен, в нём играет музыка «как в лифте» — раздел «Карты и порталы», «Лицензии» |
|
||||
| ❌ | Серверных проверок (антиспуфинг позиций, античит): сервер только пересылает |
|
||||
| ❌ | Сохранения прогресса, аккаунтов |
|
||||
| ❌ | Автоматических тестов в CI (есть боты для ручного прогона, см. «Тесты без окна») |
|
||||
@@ -50,7 +58,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). Действия, которых нет в `project.godot`, добавляет `core/settings.gd` кодом при старте: `pause` (Esc, Start), `zoom` (LT, R3), `look_left/right/up/down` (правый стик) и все кнопки геймпада.
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
@@ -60,42 +68,69 @@ 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 / клик | отпустить / захватить мышь |
|
||||
| Esc | **меню паузы** (мышь освобождается); «Продолжить» или повторный Esc — назад в игру и захват мыши |
|
||||
| клик по игре | захватить мышь |
|
||||
|
||||
Бег по стене — в воздухе у стены с зажатым «вперёд» на скорости. Ctrl не используется: в браузере `Ctrl+W` закрывает вкладку.
|
||||
**Геймпад Xbox** (правый большой палец всегда на стике обзора, поэтому прыжок продублирован на LB, а рывок на RB):
|
||||
|
||||
| Кнопка | Действие |
|
||||
|---|---|
|
||||
| Левый стик / правый стик | движение / обзор (кривая 1,6: точно у центра, быстро на краю; скорость — в настройках) |
|
||||
| A или LB | прыжок (у уступа — подтянуться, у стены — от стены, в воздухе — двойной) |
|
||||
| RT или L3 | спринт (держать) |
|
||||
| X или RB | рывок |
|
||||
| B (держать) | присед; **RT + B на бегу** — подкат |
|
||||
| LT или R3 | зум |
|
||||
| Y | «готов» в Race; забег заново в Parkour |
|
||||
| Back | таблица лидеров (Tab) |
|
||||
| Крестовина вниз (держать 1,5 с) | сдаться в гонке |
|
||||
| Start | меню паузы (в меню: крестовина/левый стик, A — выбрать, B — назад) |
|
||||
|
||||
Ник по геймпаду меняется в меню паузы. Бег по стене — в воздухе у стены с зажатым «вперёд» на скорости. Ctrl не используется: в браузере `Ctrl+W` закрывает вкладку.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
project.godot, export_presets.cfg, icon.svg
|
||||
project.godot, export_presets.cfg
|
||||
branding/ splash.png (заставка загрузки), icon.svg (иконка, фавиконка) — «Aman's World»
|
||||
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: скачивает пакет карты и подключает его (см. «Загрузка карт»)
|
||||
settings.gd автолоад Settings: настройки (user://settings.cfg) и раскладка геймпада в InputMap
|
||||
pause_menu.gd автолоад PauseMenu: меню паузы Esc/Start (см. «Меню паузы и настройки»)
|
||||
overlay.gd автолоад Overlay: панель ника (N) и таблица лидеров (Tab) поверх любой карты
|
||||
lb_view.gd LbView: таблица рейтинга карты (панель Tab, итоги Race/Parkour) и общий стиль панелей
|
||||
player/
|
||||
player.tscn, player.gd игрок от первого лица: всё паркур-управление, смерть и возрождение
|
||||
character/ character.glb — модель киберниндзя и 14 анимаций (исходник и скрипт сборки: `../Blender models/character_01/`)
|
||||
objects/
|
||||
portal.gd портал на другую карту (Area3D, рисуется кодом)
|
||||
vent.gd вентиляционный поток (Area3D): тянет игрока вверх, горизонтальная скорость сохраняется (Race 2)
|
||||
boost_strip.gd ускоряющая полоса (Area3D): добирает скорость до MAX_SPEED вдоль полосы (Race 2)
|
||||
chasers/
|
||||
chaser.gd общий «мозг» преследователя: навмеш, зрение, погоня, блуждание, поимка, синхронизация
|
||||
rock/ камень: rock.tscn, Rock.glb (+ извлечённая текстура), Guitar.ogg
|
||||
verity/ Верити: verity.tscn, verity2.glb (+ текстура), verity.ogg (звук из видео)
|
||||
maps/ у каждой карты своя папка — она самодостаточна и экспортируется отдельным пакетом
|
||||
hub/ стартовая комната: hub.tscn, hub.gd (порталы строятся из списка PORTALS)
|
||||
hub/ стартовая комната: hub.tscn, hub.gd (порталы из списка PORTALS, музыка), hub_decor.gd (оформление), audio/music.ogg + CREDITS.md
|
||||
sandbox/ песочница: sandbox.tscn, forest.gd, birds.gd, levitate.gd, nav_bake.gd,
|
||||
floor/ sky/ audio/ models/ (здания, башня, обелиск, деревья, Эмпайр-стейт…)
|
||||
race/ race.tscn, race.gd, race_rules.gd (правила гонки), race_data.gd (генерируется), race.glb, sky.png, audio/music.ogg
|
||||
race/ race.tscn, race.gd, race_rules.gd (правила гонки, сдача, итоги, рейтинг — **общие с Race 2**), race_data.gd (генерируется), baked.gdshader, race.glb, sky.png, audio/music.ogg
|
||||
race2/ race2.tscn, race2.gd (внешний вид, ворота, лазеры, вентиляторы, полосы), race2_data.gd (генерируется), baked.gdshader, race2.glb, sky.png, audio/music.ogg; правила берёт из maps/race/race_rules.gd
|
||||
parkour/ parkour.tscn, parkour.gd (логика: чекпоинты, лазеры, дождь, звук, окружение), parkour_data.gd (генерируется),
|
||||
baked.gdshader, parkour.glb (из Blender), sky.png (панорама), audio/music.ogg; см. «Заметки для будущего меня»
|
||||
backrooms/ backrooms.tscn, backrooms.gd, backrooms_nav.res (запечённый навмеш),
|
||||
model/ (готовая модель «Backrooms VR» + 27 извлечённых текстур + CREDITS.md)
|
||||
deploy/ боевой конфиг nginx, systemd-юнит, project.godot выделенного сервера
|
||||
tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (боты)
|
||||
tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (боты, замеры, двухклиентные тесты ников/рейтинга/гонки)
|
||||
```
|
||||
|
||||
**Правила, которые стоит соблюдать**
|
||||
@@ -113,11 +148,12 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
|
||||
|
||||
| Карта | Сцена | Пакет | Что там |
|
||||
|---|---|---|---|
|
||||
| Хаб | `maps/hub/hub.tscn` | в основном пакете | Комната 28×18 м, строится кодом (пол-грид, стены, свет). Порталы SANDBOX и BACKROOMS в ряд по центру, влезает 6. Безопасна, преследователей нет |
|
||||
| Хаб | `maps/hub/hub.tscn` | в основном пакете | «Лобби»: зал 48×32×10 м, строится кодом (`hub.gd` — стены, порталы, доски, музыка; `hub_decor.gd` — оформление). Порталы в ряд у северной стены с шагом 6 м (влезает 7; SANDBOX `x=-12`, RACE 2 `x=12`, `z=-15`), игрок появляется на `(0, 1.2, 11)`. В центре платформа с вращающейся голограммой, пол с латунными кольцами и дорожкой к порталам, 6 колонн с неоновыми прорезями, потолочные светильники (свечение — `glow`), в боковых стенах шесть окон с ночным городом (шейдер), лавки (на них можно запрыгнуть), пылинки в воздухе, табло «ЛОББИ» на южной стене. Над порталами карт «на время» — доски с топ-3 (см. «Ники и рейтинг»). Музыка `maps/hub/audio/music.ogg` («Local Forecast - Elevator», Kevin MacLeod, CC-BY) играет по кругу, плавно нарастая за 3 с, −9 дБ. Безопасна, преследователей нет |
|
||||
| Песочница | `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» ниже |
|
||||
| Race 2 | `maps/race2/race2.tscn` | `race2.pck` (9,5 МБ) | «Верхние ярусы»: гонка по крышам и мостам над городом, 13 ворот, ~1,2 км безопасного пути; на каждом участке безопасный / рискованный / секретный путь; вентиляторы-подбросы и ускоряющие полосы. Правила те же, что у Race (общий код). Раздел «Карта Race 2» |
|
||||
|
||||
**Правка и пересборка Parkour** (всё в `Blender models/parkour_02/`):
|
||||
1. `layout.py` — единственный источник: плиты, стены, лазеры, чекпоинты и расчёт зазоров под мувсет (замеры ботом: прыжок на спринте 15 м, двойной 25 м, прыжок+рывок 19 м, двойной+рывок 28 м, бег по стене 1,8 с / 23 м, подтягивание на уступ до ~2,6 м; **стенами высоту не набрать** — прыжков от стены без бега только 2 за полёт).
|
||||
@@ -151,6 +187,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 +199,97 @@ 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, время)` .
|
||||
|
||||
**Проверка по существу** (всё в `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`.
|
||||
|
||||
## Карта Race 2 («Верхние ярусы»)
|
||||
|
||||
**Состояние: фаза 1** (раскладка, геометрия, ворота, правила, проходимость ботами, предварительный свет). **Фаза 2** — после одобрения владельцем: чистовое запекание (384+ сэмплов), своё небо (`build_sky.py`), своя музыка. Палитра своя (холодная индиго-ночь, пурпур и ледяная бирюза; ворота тусклые индиго / очередные маджента / пройденные циан), небо перерисовано (`build_sky.py`), свет запечён на 384 сэмплах. Музыка — «On My Way to Hell» (Poloz), с 1:40 исходника (`ffmpeg -ss 100 -i исходник.mp3 -vn -map_metadata -1 -ac 1 -ar 32000 -c:a libvorbis -q:a 0 maps/race2/audio/music.ogg`, ~3:40, 1,2 МБ; исходник вне проекта: `source-assets/music/`); играет с «СТАРТ!» с начала по кругу (`loop = true`, затухание 0,8 с). Стык петли на слух/по уровню не проверен.
|
||||
Исходники: `Blender models/race_02/` (README там же; репозитория у папки нет — файлы не коммитятся). Концепция согласована с владельцем: вертикальность и поток скорости в духе Ghostrunner, но атмосфера как у Race/Parkour (дождь, смог, мокрый бетон, натриевый/красный/больной зелёный свет, надписи-приказы), туман 0,011.
|
||||
|
||||
Файлы: `maps/race2/` — `race2.tscn`, `race2.gd` (внешний вид, ворота со световыми столбами, лазеры, вентиляторы, полосы, срыв, дождь без дождя в траншее и шахте, гул, портал), `race2_data.gd` (**генерируется** `build_race.py`, не править),
|
||||
`race2.glb`, `sky.png`, `baked.gdshader`, `audio/music.ogg`. **Правила — общие с Race:** `race2.gd` создаёт тот же `maps/race/race_rules.gd` и передаёт ему `data` (= `race2_data.gd`) и `rating_id` (= `"race2"`); `race_rules.gd` берёт данные у карты
|
||||
(`m.get("data")`, у Race — запасной `load` его `race_data.gd`). Пакет `race2.pck` (9,5 МБ) собирается пресетом **Race2** (в нём `include_filter`: `maps/race/race_rules.gd` и файлы карты); скрипты `objects/vent.gd` и `objects/boost_strip.gd` — в **основном** пакете.
|
||||
Рейтинг: `Net.TIMED_MAPS["race2"] = {"min": 44}` (≈ 60 % от лучшего круга ботом по всем секретам ~72 с), `Net.MAP_TITLES["race2"] = "Гонка 2"`; в хабе портал «RACE 2» пятым в ряду.
|
||||
|
||||
**Новые объекты** (локальный эффект на игроке, сетевой код не тронут; в `player/player.gd` — только хуки `apply_vent()` и `apply_boost()` и константы `VENT_ACCEL`, `VENT_MAX_RISE`, `BOOST_ACCEL`, `EFFECT_HOLD`; остальная физика и `MAX_SPEED = 22` не менялись):
|
||||
- **Вентиляционный поток** (`objects/vent.gd`): пока игрок в столбе, вертикальная скорость растёт до **16 м/с** (ускорение 80 м/с²; раньше 11 и 60), горизонтальная сохраняется, двойной прыжок возвращается. Выход из столба на 16 м/с даёт ещё ~13 м высоты (v²/2g), поэтому ступень башни 12,5 м берётся с перелётом ~3 м; ступени 14–15 м в пробах ботом вели к промахам и оставлены 12,5. Шахта-лифт — скрытый поток (`hidden`: без столба, щелей и света), над окном знака нет. Визуал: тёмная решётка со светящимися щелями, мерцающий зелёный столб, слабый свет.
|
||||
- **Ускоряющая полоса** (`objects/boost_strip.gd`): набор скорости вдоль полосы до 22 м/с (не выше `MAX_SPEED`), потом на земле она гасится как обычно (14 м/с²), в воздухе сохраняется. Измерено: на 22 м/с одиночный прыжок 25 м, двойной 42 м; **рывок сбрасывает скорость до 13**. Стрелки пурпурно-белые, бегут по ходу (статичные ледяные — подсказки трассы).
|
||||
|
||||
**Участки и три пути** (подробности и координаты — `Blender models/race_02/README.md`, `layout.py`): старт на верхней платформе (8 слотов, шаг 2,8 м) → 2) мосты и крыши между башнями → 3) фасады: бег по четырём стенам → 4) площадь с краном → 5) пульсирующие лазерные мосты → 6) спуск по крышам → 7) башня-спираль из четырёх вентиляторов; на крыше ядра — финиш и портал.
|
||||
Безопасный путь 1085 м до подножия башни + 136 м башня = **1221 м** (план ~1100; можно сжать зоны 4 и 6), 13 ворот (ширина до 100 м там, где ветки расходятся широко), круг бота по безопасному пути ~99 с.
|
||||
Безопасный: зазоры 8–12 м, подъём до 1,5 м, спуск до 9,2 м. Рискованный: зазоры 16–24 м (двойной прыжок + рывок), бег по стенам. Секретный: скрытые тёмные площадки без подсветки и входы без подписей — окно «пустого квартала», траншея под ямой (подкат), техпроход под лазерами, стена w6, шахта-лифт.
|
||||
Срыв ниже последних ворот на 32 м = «СОРВАЛСЯ!», возрождение на воротах (центр каждых ворот лежит на плите — проверено).
|
||||
|
||||
**Проверка ботами** (`Blender models/race_02/tests/`, `run.sh`; в headless с `--fixed-fps 60` — быстрее реального времени). Все ветки пройдены (результаты ниже — последний прогон, оценочные времена бота от одной точки зоны до другой, секунды):
|
||||
|
||||
| Зона | безопасный | рискованный | секретный | выигрыш секрета |
|
||||
|---|---|---|---|---|
|
||||
| 2 мосты | 13,2 | 10,8 | 9,5 | 3,7 |
|
||||
| 3 фасады | 14,5 | 9,9 (4 стены, 4 из 4 попыток) | 10,3 | 4,2 |
|
||||
| 4 площадь | 12,8 | 7,6 | 7,8 | 5,0 |
|
||||
| 5 лазеры | 10,1 | 11,3 | 9,1 | 1,0 (бот не теряет времени на ритм лазеров) |
|
||||
| 6 спуск | 15,5 | 12,4 | 10,9 (стена w6: 3 из 4 попыток, с запасом в 1,5 м не проходит) | 4,6 |
|
||||
| 7 башня | 17,4 | — | 9,5 | 7,9 |
|
||||
|
||||
Итого все секреты вместе экономят ~26 с из ~99 (оценка бота; человек на секретах рискует сорваться на последние ворота). **Правила на race2:** `MAP=race2 tools/test/race_game_test.sh` (бот проводит игроков через ворота телепортами по 0,35 м за кадр, на лазерном мосту выбирает полосу — проверяются отсчёт, места, итоги, один START, возрождение лицом по трассе, зритель; проходимость проверена отдельно). Рейтинг этим тестом не проверяется: круг бота ~60 с — запись возможна, но тест рейтинг не сверяет.
|
||||
Пакет: `godot --headless --export-pack Race2 /tmp/x/race2.pck`, затем `strings` по `sky.png`, `baked.gdshader`, `race2_data`, `race_rules.gd`, `music.ogg`, `race2.glb`, `race2.gd` (каждое > 0; vent/boost_strip в этом пакете нет — они в основном). Через портал хаба: `tools/test/race2_check.txt`.
|
||||
|
||||
**Что не сделано / известно:** после смены силы вентиляторов (11 -> 16 м/с) проходимость башни и траншеи проверена ботом только выборочно (cmp7_safe, secret7, cmp4_secret, risky4 прошли), полный прогон и таблица времён выше — до этой правки, не перемерены; подножие башни всё ещё тёмное; `race2.glb` 8,4 МБ против 5,2 у Race (в фазе 2 прореживать фон); у зон 1 и 7 нет рискованной ветки; зазор для безопасного пути на спуске к `lane` — 8 м при спуске 9,2 м; не мерялись: нагрузка в браузере и звук. **Выкладка:** `net/net.gd` изменён только константами (`TIMED_MAPS`, `MAP_TITLES`), RPC те же — но сервер без нового `net.gd` **не примет рекорды race2** (запись отклонит: карты нет в `TIMED_MAPS`), поэтому после пуша `grid-world` запустить `tools/deploy_server.sh`; порядок и бэкап — как в «Деплой».
|
||||
|
||||
## Ники и рейтинг
|
||||
|
||||
**Профиль** — `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}}
|
||||
const MAX_RECORD_TIME := 3600.0
|
||||
```
|
||||
Минимумы — **страховка от мусора, не античит**: время быстрее `min` или больше 3600 с сервер молча отбрасывает. Подкрутить — поправить числа (и выложить сервер вместе с клиентом, см. «Деплой»; клиент использует тот же список, чтобы знать, где рисовать доски).
|
||||
- `submit_record(map_id, time)`: клиент сообщает свой результат (сервер берёт `client_id` и ник по peer). Проверки: карта из списка **и** игрок сейчас в её комнате, время конечное и в пределах, есть профиль, лимит **6 записей в минуту** на игрока (лишнее молча отбрасывается). Хранится лучшее время каждого `client_id` (худшее не затирает; ник обновляется при смене ника и/или улучшении), не более **100** записей на карту (худшие вытесняются). Ответ: `recv_record_result(map_id, time, rank, personal, map_record)` (место, «новый личный рекорд», «новый рекорд карты» = личный и 1-е место) и `recv_leaderboard`.
|
||||
- `request_leaderboard(map_id)` -> `recv_leaderboard(map_id, top, my_rank, my_time)`: `top` — до 10 записей `[ник, время, unix-ts]` по возрастанию (при равенстве — кто раньше), `my_rank` — место запросившего (0 — нет записи), `my_time` — его лучшее. Лимит **12 запросов за 10 с** на игрока. Клиент хранит ответы в `Net.boards[map_id]`, сигнал `Net.leaderboard_updated(map_id)`.
|
||||
- **Хранилище:** `user://leaderboard.json` на сервере. На боевом сервере `user://` = `/var/lib/my-world/.local/share/godot/app_userdata/world2-server/` (служба `my-world`: `StateDirectory=my-world`, `HOME=/var/lib/my-world`, DynamicUser) — папка постоянная, переживает перезапуск службы и `tools/deploy_server.sh` (тот копирует только `net/` и `project.godot`). Формат:
|
||||
```json
|
||||
{"version": 1, "maps": {"race": {"<client_id>": {"nick": "Алиса", "time": 52.318, "ts": 1793250000}}, "parkour": {}}}
|
||||
```
|
||||
Запись атомарная (во временный `.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`, иначе окно подключится к реальному серверу и затрёт подставленное).
|
||||
|
||||
## Меню паузы и настройки
|
||||
|
||||
`core/pause_menu.gd` (автолоад `PauseMenu`) + `core/settings.gd` (автолоад `Settings`, объявлен раньше `Overlay`). Меню открывается по **Esc / Start** (действие `pause`), игра при этом идёт дальше (мультиплеер), а игрок стоит: `Net.input_blocked = true`, как при вводе ника; игровые клавиши за меню не работают (меню забирает событие раньше `Overlay`). Закрывается кнопкой «Продолжить», повторным Esc/Start или B; мышь снова захватывается.
|
||||
- **Главная:** Продолжить · **Выйти в хаб** (только если карта не хаб: `MapLoader.enter("hub", …)`, хаб в основном пакете) · Таблица лидеров · Сменить ник · Настройки · Управление.
|
||||
- **Настройки** (`user://settings.cfg`, пишется при закрытии меню; кнопка «По умолчанию»): чувствительность мыши ×0,2–3 (к `player.MOUSE_SENSITIVITY`), чувствительность стика ×0,3–2,5 (`Settings.STICK_LOOK_SPEED` = 3,6 рад/с), поле зрения 60–100° (`player.base_fov` берётся из `Settings.fov` каждый кадр), громкость (шина Master), инверсия вертикали (мышь и стик). Слайдеры и кнопки управляются геймпадом через стандартные `ui_*`.
|
||||
- **Управление:** таблица «клавиатура / геймпад».
|
||||
- **Esc в браузере:** при захваченной мыши браузер сам снимает захват и событие Esc до игры **не доходит**. Поэтому `PauseMenu._process` открывает меню и по факту «мышь была захвачена ≥ 0,5 с и перестала быть» (если не открыт ввод ника и не идёт загрузка карты); после закрытия 0,6 с это не срабатывает (браузер мог отказать в повторном захвате без клика — тогда клик по игре захватит мышь, как раньше). **В живом браузере не проверялось.**
|
||||
- Геймпад: обзор правым стиком — `player._process` (кадр за кадром, не в физике), мышь — `player._mouse_look`. Движение стиком аналоговое по направлению, но скорость по-прежнему «шаг/спринт» (`direction` нормализуется), как и раньше с клавиатурой.
|
||||
- Проверка: `godot --headless --path . res://tools/test/pause_test.tscn -- --client --server-url=ws://127.0.0.1:1` — раскладка геймпада в InputMap, открытие/закрытие, блокировка, чувствительность и инверсия, FOV, обзор стиком, A и RT, сохранение, «Выйти в хаб». Кадры меню и хаба: `hub_tour.tscn` (`--menu=main|settings|controls`).
|
||||
|
||||
## Преследователи
|
||||
|
||||
@@ -214,6 +331,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_*`): подкат, бег по стене (+ сторона стены), рывок, подтягивание, «в воздухе». Присед передаётся как подкат; призрак отличает их по скорости. Скорость и двойной прыжок призрак считает сам по смещению позиции — в протокол они не входят.
|
||||
|
||||
@@ -222,7 +342,7 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
|
||||
|
||||
## Загрузка карт пакетами (веб)
|
||||
|
||||
Основной пакет игры (`index.pck`) — только хаб, игрок, сеть и порталы (около 70 КБ). Движок — `index.wasm` (39,5 МБ, ~10 МБ с gzip).
|
||||
Основной пакет игры (`index.pck`) — только хаб (с музыкой), игрок, сеть, меню и порталы (около 1,6 МБ, из них 1,2 МБ — музыка хаба). Движок — `index.wasm` (39,5 МБ, ~10 МБ с gzip).
|
||||
Карты — отдельные `.pck`: `world.pck` (13,6 МБ), `backrooms.pck` (21,3 МБ). Портал вызывает `MapLoader.enter(map, scene, pack, title)`:
|
||||
|
||||
1. если сцена уже есть в проекте (редактор, десктоп, пакет уже скачан) — переход сразу;
|
||||
@@ -267,14 +387,16 @@ python3 tools/serve_web.py ../grid-world 8080 # nginx-подобно: COOP/C
|
||||
godot --headless --path /tmp/ts & # сервер
|
||||
godot --headless --path /tmp/tp res://maps/sandbox/sandbox.tscn -- --client --name=A &
|
||||
godot --headless --path /tmp/tp res://maps/sandbox/sandbox.tscn -- --client --name=B
|
||||
# цепочка карт на реальных пакетах (портал SANDBOX в хабе на (-2.25, -7.5), BACKROOMS на (2.25, -7.5)):
|
||||
# цепочка карт на реальных пакетах (порталы хаба в ряд на z=-15 с шагом 6 м: SANDBOX x=-12, BACKROOMS x=-6; в `--tp` ниже — SANDBOX):
|
||||
/tmp/tp/tools/export_all.sh /tmp/site && (cd /tmp/site && python3 -m http.server 8777 --bind 127.0.0.1 &)
|
||||
godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:-2.25,1,-7.5
|
||||
godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:-12,1,-15
|
||||
```
|
||||
Для картинок: `godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/a.png --quit-after 25 res://maps/hub/hub.tscn` (окно мелькнёт; кадры — `a00000020.png`).
|
||||
В браузере: Chrome (flatpak `com.google.Chrome`) `--headless=new --enable-logging=stderr --v=0 http://127.0.0.1:8080` — вывод Godot виден в строках `CONSOLE`.
|
||||
|
||||
**Готовые проверки** (`tools/test/`, запускаются как сцены: в режиме `--script` автозагрузки недоступны при компиляции, поэтому только `res://…tscn`; `-- --client`, иначе headless поднимет сервер на порту 9080):
|
||||
- Race 2: `MAP=race2 tools/test/race_game_test.sh` (правила, см. «Карта Race 2»); боты проходимости — `Blender models/race_02/tests/` (`run.sh`, `run_all.sh`, `compare.sh`, `frames.sh`); пакет через портал хаба — `tools/test/race2_check.txt`.
|
||||
- `pad_menu_test.tscn` — геймпад в меню (фокус, крестовина, A); `pause_test.tscn` — меню паузы, настройки, геймпад (см. «Меню паузы и настройки»); `hub_tour.tscn` — кадры хаба и меню паузы.
|
||||
- `measure_movement.tscn` — замеры мувсета (прыжки, рывок, стена); `mantle_test.tscn` — подтягивание на 40 вариантах уступов; `parkour_gaps.tscn` — проходимость контейнеров; `parkour_tour.tscn` — облёт карты со снимками (см. заголовок файла).
|
||||
- **Проверяй по существу, а не по имени сцены.** Для карт из пакетов: собери тестовый проект (`make_test_project.sh`), в его `bot.gd` добавь печать фактов (есть ли у `current_scene` скрипт, подменён ли материал, есть ли `PanoramaSkyMaterial`, играет ли музыка, сколько лазеров) и пройди портал с `--pack-url`. Готовый код проверки — `tools/test/parkour_check.txt` (ловит пропавшие `sky.png`/шейдер/музыку/логику).
|
||||
- Ввод ботом: `Input.action_press("jump")` работает для зажатия и `is_action_pressed`; рывок и подкат ловятся в `_unhandled_input`, поэтому для них нужно `Input.parse_input_event(InputEventAction)` с `pressed=true`.
|
||||
@@ -351,6 +473,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 см (это нормально).
|
||||
@@ -358,6 +490,16 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
|
||||
- В `--write-movie` с `canvas_items`+`expand` видимая область шире окна, и подписи у левого края обрезаются; HUD слева отступает на 64 px.
|
||||
- Кадры гонки: `race_tour.tscn --mode=hud` (R, отсчёт, пробег, итоги). Подробности — раздел «Карта Race».
|
||||
|
||||
**Race 2 — что выяснено**
|
||||
- Дальность прыжка (ровная плита, бот `race_02/tests/measure.gd`): на 13 м/с — 14,7 / 24,9 / 29,1 м (одиночный / двойной / рывок+двойной), на 17 — 19,4 / 32,7 / 37,1, на 22 — 25,1 / 42,3 / 46,5. **Рывок сбрасывает скорость до `DASH_EXIT_SPEED` = 13**: разгон полосой до рывка пропадает, после полосы прыгать без рывка.
|
||||
- Бег по стенам съедает высоту: 4 стены каньона отнимают ~9 м — низ каньона (и всё после него) сделан на 9,2 м ниже крыш (`layout.ZP`); при 3 стенах хватало 4,3 м.
|
||||
- Вентилятор шириной 6 м при горизонтальной скорости 13 м/с держит игрока ~0,46 с: чтобы подняться на 12,5 м, нужны сильный поток (`VENT_ACCEL = 60`, `VENT_MAX_RISE = 11`) и двойной прыжок. Шахта-лифт работает потому, что у шахты есть стены: игрок упирается в них, гасит скорость и поднимается весь путь.
|
||||
- Подкат под плитой: центр капсулы стоит на 1,18 м над полом, полная высота ~2,4 м — под мостом нужен просвет ≥ 2,5 м (2,2 м не пролезть). Подкат с 22 м/с стартует со скорости ~27 и скользит ~55 м.
|
||||
- Центр ворот лежит на плите: возрождение происходит ровно там. Широкие ворота на разлёте веток (cp6 — 100 м, cp10 — 56 м) допустимы: окно по высоте у пересечения — от −3 до +9 м от центра ворот.
|
||||
- Лазерные пары (левая/правая половина моста, период 3 с, горят по 1 с с фазой 1,5 с) оставляют свободную полосу почти всё время (обе заняты ~10 % времени, при учёте поправки ±0,4 с).
|
||||
- Боты (граблям не повторять): точка маршрута над пропастью разворачивает бота в воздухе (ориентиры только на плитах); прыжок держать до конца полёта (отпустил — «срезанный» прыжок вдвое короче); у ботов с `--client` запуск рядом с локальным тестовым сервером добавляет лишнего игрока в комнату; **не `pkill -f` с путём теста** (убьёт свою оболочку) — `ps … | grep '[g]odot'` и `kill` по PID.
|
||||
- Запекание: `BAKE_SAMPLES=64 ./rebuild.sh` — проба за ~30 с; на дне между башнями (подножие финальной башни) свет получается тёмным — добавлены прожекторные панели на стенах ядра; чистовое запекание — фаза 2.
|
||||
|
||||
## Дорожная карта
|
||||
|
||||
### Телефон — не планируется
|
||||
@@ -366,23 +508,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».
|
||||
|
||||
@@ -407,8 +543,14 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
|
||||
- **Выкладка сервера:** `ghost.gd` лежит и на сервере, где модели нет, поэтому модель грузится `load()` внутри `setup()`, а не `preload`.
|
||||
- Изменить позы: правь функции анимаций в скрипте, перезапусти его в Blender, заново экспортируй GLB и импортируй в Godot.
|
||||
|
||||
## Название и заставка
|
||||
|
||||
Игра называется **Aman's World** (`config/name`). Заставка загрузки — `branding/splash.png` (1280×720, генерируется скриптом на Pillow: дождь, силуэты домов, натриевое свечение; при смене надписи/стиля перерисуй и положи на то же место), иконка и фавиконка — `branding/icon.svg`. Всё задаётся в `project.godot → [application]` (`boot_splash/image`, `boot_splash/bg_color`, `config/icon`); веб-экспорт берёт из них `index.png`, `index.icon.png` и `<title>`. **Папка пользовательских данных осталась прежней** (`config/use_custom_user_dir`, `custom_user_dir_name="world2"`): профиль (`profile.cfg`) и рекорд (`race_best.cfg`) не должны потеряться при переименовании проекта. Серверный проект (`deploy/server/project.godot`) — отдельный, его имя не менялось.
|
||||
|
||||
## Журнал изменений (окт 2026)
|
||||
|
||||
Последнее (31 окт 2026): геймпад Xbox, меню паузы с выходом в хаб и настройками, увеличенный и оформленный хаб с музыкой. Подробности — «Управление», «Меню паузы и настройки», «Карты и порталы». Выкладка сервера **не нужна** (`net/*` не менялся).
|
||||
|
||||
Сделано одной серией работ (хронологически):
|
||||
1. **Персонаж:** модель киберниндзя со скелетом (752 треугольника, 14 анимаций, IK ног) вместо капсул; цвет свечения от id игрока; свой персонаж спрятан от своей камеры. Сальто камеры при двойном прыжке делалось и убрано по просьбе.
|
||||
2. **Подтягивание на уступы** починено (не работало от ~2 м на всех картах), замеры мувсета и тестовые сцены в `tools/test/`.
|
||||
@@ -416,9 +558,14 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
|
||||
4. **Race:** гонка на ногах (трасса 633 м из Blender, три развилки, 8 ворот), правила и HUD, новые сетевые события, песня на «СТАРТ!»; сделано агентами, проверено двухклиентным тестом на локальном сервере.
|
||||
5. **Инфраструктура:** пресеты экспорта карт (с `include_filter` для файлов, грузимых по строке), проверка пакетов «по существу», выкладка сайта и сервера по порядку из «Деплой».
|
||||
6. **Замеры:** вкладка в браузере ~300 МБ.
|
||||
7. **Ники, общий рейтинг, таблица Tab, доски в хабе, сдача в гонке (G), таймер Parkour**, название «Aman's World» (заставка, иконка).
|
||||
8. **Карта Metro (гонка вниз 1495 м со скользящими рельсами и бустером) сделана и УДАЛЕНА по решению владельца** («полный мусор», окт 2026): не понравилась. Из истории не восстанавливай без просьбы. Что узнали: (а) владельцу важнее всего скорость, маневренность и ощущение потока, как у первой гонки Race; (б) прежде чем отдавать большую карту агентам, показать владельцу концепцию и кадры черновика; (в) идея «скользящий рельс» (подхват в воздухе, езда 36 м/с, сход прыжком) работала технически, но потолок `MAX_SPEED = 22` обрезает скорость на сходе; (г) слишком светлые «пятнистые» залы из-за запекания и белые засветки — следить за силой света при запекании.
|
||||
|
||||
9. **Race 2 «Верхние ярусы» (фаза 1)**: вторая гонка по крышам и мостам над городом, 13 ворот, ~1,2 км, три пути на каждом участке, вентиляторы-подбросы и ускоряющие полосы (хуки в `player.gd`), общий с Race код правил (`race_rules.gd` берёт данные у карты), портал в хабе, рейтинг `race2`. Проходимость каждой ветки доказана ботами; чистовое запекание, небо и музыка — фаза 2.
|
||||
|
||||
## Лицензии и авторство
|
||||
|
||||
- Модель карты Backrooms — «Backrooms VR», автор VTX (https://sketchfab.com/VTX_car), **CC-BY 4.0** (`maps/backrooms/model/CREDITS.md`).
|
||||
При публикации нужно указывать автора.
|
||||
- Музыка хаба — «Local Forecast - Elevator», Kevin MacLeod (https://incompetech.com), **CC-BY 4.0** (`maps/hub/audio/CREDITS.md`). При публикации нужно указывать автора.
|
||||
- Остальные модели и звуки — материалы владельца проекта.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect width="124" height="124" x="2" y="2" fill="#0b0b0e" stroke="#c0281a" stroke-width="4" rx="14"/><path d="M26 98 L64 24 L102 98 M42 72 H86" fill="none" stroke="#ff8a3d" stroke-width="10" stroke-linecap="square" stroke-linejoin="miter"/><rect x="20" y="106" width="88" height="4" fill="#c0281a"/></svg>
|
||||
|
After Width: | Height: | Size: 393 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dutx7rbsx2tuy"
|
||||
path="res://.godot/imported/icon.svg-3d9dbe7d709cf7a7c4314775087c0608.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://branding/icon.svg"
|
||||
dest_files=["res://.godot/imported/icon.svg-3d9dbe7d709cf7a7c4314775087c0608.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bc3r54dm4n65j"
|
||||
path="res://.godot/imported/splash.png-0d6de845ee625fbaec4bfb9f6c0e9e33.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://branding/splash.png"
|
||||
dest_files=["res://.godot/imported/splash.png-0d6de845ee625fbaec4bfb9f6c0e9e33.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
class_name LbView
|
||||
extends VBoxContainer
|
||||
# Таблица рейтинга карты (топ-10 + «Вы: N-й»), данные берутся из Net.boards и обновляются сами по сигналу.
|
||||
# Одна и та же таблица в панели Tab, в итогах гонки и итогах Parkour: LbView.create(id_карты).
|
||||
# Ники выводятся обычными 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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://buo8m8ndkcb67
|
||||
+220
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcudxskqa5bx6
|
||||
@@ -0,0 +1,331 @@
|
||||
extends Node
|
||||
# Автолоад «PauseMenu»: меню паузы по Esc / Start. Игра идёт дальше (это мультиплеер), но игрок стоит
|
||||
# (Net.input_blocked, как при вводе ника). Страницы: главная, настройки, управление.
|
||||
#
|
||||
# В браузере Esc при захваченной мыши не доходит до игры — браузер просто снимает захват. Поэтому меню
|
||||
# открывается и по самому факту «мышь была захвачена и перестала быть» (см. _process).
|
||||
|
||||
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 HUB_SCENE := "res://maps/hub/hub.tscn"
|
||||
const REOPEN_GUARD := 0.6 # после закрытия столько секунд снятие захвата не считается нажатием Esc
|
||||
const CAPTURED_MIN := 0.5 # мышь должна быть захвачена хотя бы столько, чтобы потеря захвата считалась Esc
|
||||
|
||||
var open := false
|
||||
|
||||
var _layer: CanvasLayer
|
||||
var _pages := {} # имя -> Control
|
||||
var _page := "main"
|
||||
var _title: Label
|
||||
var _subtitle: Label
|
||||
var _hub_btn: Button
|
||||
var _lb_btn: Button
|
||||
var _captured_time := 0.0
|
||||
var _guard := 0.0
|
||||
var _sliders := {} # имя настройки -> {slider, value}
|
||||
var _invert: CheckButton
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_build()
|
||||
_layer.visible = false
|
||||
|
||||
# ---------- открытие / закрытие ----------
|
||||
|
||||
func open_menu() -> void:
|
||||
if open:
|
||||
return
|
||||
open = true
|
||||
Net.input_blocked = true
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_show_page("main")
|
||||
_layer.visible = true
|
||||
|
||||
func close_menu() -> void:
|
||||
if not open:
|
||||
return
|
||||
open = false
|
||||
_guard = REOPEN_GUARD
|
||||
Settings.save()
|
||||
_layer.visible = false
|
||||
get_viewport().gui_release_focus()
|
||||
if not Overlay.nick_open:
|
||||
Net.input_blocked = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_guard = maxf(_guard - delta, 0.0)
|
||||
if open:
|
||||
_captured_time = 0.0
|
||||
return
|
||||
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
_captured_time += delta
|
||||
return
|
||||
# Захват пропал. Если до этого мышь побыла захвачена и мы сами её не отпускали — это Esc в браузере.
|
||||
var was := _captured_time >= CAPTURED_MIN
|
||||
_captured_time = 0.0
|
||||
if was and _guard <= 0.0 and _can_open():
|
||||
open_menu()
|
||||
|
||||
func _can_open() -> bool:
|
||||
return not Overlay.nick_open and not MapLoader._busy and get_tree().current_scene != null
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if open:
|
||||
if event.is_action_pressed("pause") or event.is_action_pressed("ui_cancel"):
|
||||
if _page == "main":
|
||||
close_menu()
|
||||
else:
|
||||
_show_page("main")
|
||||
# Игровые клавиши (R, N, Tab, G…) за меню не работают
|
||||
if not (event is InputEventMouseMotion):
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if event.is_action_pressed("pause") and not event.is_echo():
|
||||
if Overlay.nick_open or Overlay.lb_open or not _can_open():
|
||||
return # Esc закрывает панель ника / таблицу — это делает Overlay
|
||||
open_menu()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
# ---------- страницы ----------
|
||||
|
||||
func _show_page(name: String) -> void:
|
||||
_page = name
|
||||
for k in _pages:
|
||||
_pages[k].visible = (k == name)
|
||||
match name:
|
||||
"main":
|
||||
_title.text = "ПАУЗА"
|
||||
var map_title: String = Net.MAP_TITLES.get(Net.current_map, "")
|
||||
_subtitle.text = "Карта: %s" % map_title if map_title != "" else ""
|
||||
_hub_btn.visible = Net.current_map != "hub"
|
||||
_lb_btn.visible = true
|
||||
_focus_first.call_deferred(_pages.main)
|
||||
"settings":
|
||||
_title.text = "НАСТРОЙКИ"
|
||||
_subtitle.text = "Сохраняются автоматически"
|
||||
_refresh_settings()
|
||||
_focus_first.call_deferred(_pages.settings)
|
||||
"controls":
|
||||
_title.text = "УПРАВЛЕНИЕ"
|
||||
_subtitle.text = ""
|
||||
_focus_first.call_deferred(_pages.controls)
|
||||
|
||||
func _focus_first(page: Control) -> void:
|
||||
for b in page.find_children("*", "Button", true, false):
|
||||
if b.visible and not b.disabled:
|
||||
b.grab_focus()
|
||||
return
|
||||
|
||||
func _build() -> void:
|
||||
_layer = CanvasLayer.new()
|
||||
_layer.layer = 30
|
||||
add_child(_layer)
|
||||
|
||||
var theme := _make_theme()
|
||||
var root := Control.new()
|
||||
root.theme = theme
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_layer.add_child(root)
|
||||
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0.0, 0.0, 0.0, 0.62)
|
||||
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(dim)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", LbView.panel_style(28))
|
||||
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", 12)
|
||||
box.custom_minimum_size = Vector2(460, 0)
|
||||
panel.add_child(box)
|
||||
|
||||
_title = LbView.make_label("ПАУЗА", 34, TEXT)
|
||||
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(_title)
|
||||
_subtitle = LbView.make_label("", 16, DIM)
|
||||
_subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(_subtitle)
|
||||
box.add_child(HSeparator.new())
|
||||
|
||||
_pages["main"] = _build_main()
|
||||
_pages["settings"] = _build_settings()
|
||||
_pages["controls"] = _build_controls()
|
||||
for k in _pages:
|
||||
box.add_child(_pages[k])
|
||||
|
||||
var hint := LbView.make_label("Esc / Start — назад", 14, DIM)
|
||||
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(hint)
|
||||
|
||||
func _make_theme() -> Theme:
|
||||
var t := Theme.new()
|
||||
t.default_font_size = 20
|
||||
var normal := _btn_style(Color(0.07, 0.075, 0.08, 0.95), Color(0.30, 0.22, 0.14))
|
||||
var hover := _btn_style(Color(0.13, 0.11, 0.09, 0.98), Color(0.75, 0.52, 0.26))
|
||||
var pressed := _btn_style(Color(0.20, 0.15, 0.10, 1.0), MINE)
|
||||
var focus := _btn_style(Color(0.13, 0.11, 0.09, 0.98), MINE)
|
||||
for cls in ["Button", "CheckButton"]:
|
||||
t.set_stylebox("normal", cls, normal)
|
||||
t.set_stylebox("hover", cls, hover)
|
||||
t.set_stylebox("pressed", cls, pressed)
|
||||
t.set_stylebox("focus", cls, focus)
|
||||
t.set_color("font_color", cls, TEXT)
|
||||
t.set_color("font_hover_color", cls, Color.WHITE)
|
||||
t.set_color("font_focus_color", cls, Color.WHITE)
|
||||
t.set_color("font_pressed_color", cls, MINE)
|
||||
t.set_font_size("font_size", cls, 20)
|
||||
t.set_stylebox("disabled", "Button", normal)
|
||||
# Слайдер: тёмная полоса и латунный заполнитель
|
||||
var track := StyleBoxFlat.new()
|
||||
track.bg_color = Color(0.1, 0.1, 0.11)
|
||||
track.set_corner_radius_all(3)
|
||||
track.content_margin_top = 4
|
||||
track.content_margin_bottom = 4
|
||||
var fill := StyleBoxFlat.new()
|
||||
fill.bg_color = Color(0.62, 0.42, 0.2)
|
||||
fill.set_corner_radius_all(3)
|
||||
t.set_stylebox("slider", "HSlider", track)
|
||||
t.set_stylebox("grabber_area", "HSlider", fill)
|
||||
t.set_stylebox("grabber_area_highlight", "HSlider", fill)
|
||||
return t
|
||||
|
||||
func _btn_style(bg: Color, border: Color) -> StyleBoxFlat:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = bg
|
||||
sb.border_color = border
|
||||
sb.set_border_width_all(2)
|
||||
sb.content_margin_left = 16
|
||||
sb.content_margin_right = 16
|
||||
sb.content_margin_top = 9
|
||||
sb.content_margin_bottom = 9
|
||||
return sb
|
||||
|
||||
func _button(text: String, cb: Callable) -> Button:
|
||||
var b := Button.new()
|
||||
b.text = text
|
||||
b.focus_mode = Control.FOCUS_ALL
|
||||
b.pressed.connect(cb)
|
||||
return b
|
||||
|
||||
func _vbox() -> VBoxContainer:
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 10)
|
||||
return v
|
||||
|
||||
func _build_main() -> Control:
|
||||
var v := _vbox()
|
||||
v.add_child(_button("Продолжить", close_menu))
|
||||
_hub_btn = _button("Выйти в хаб", _go_hub)
|
||||
v.add_child(_hub_btn)
|
||||
_lb_btn = _button("Таблица лидеров", _open_lb)
|
||||
v.add_child(_lb_btn)
|
||||
v.add_child(_button("Сменить ник", _open_nick))
|
||||
v.add_child(_button("Настройки", func(): _show_page("settings")))
|
||||
v.add_child(_button("Управление", func(): _show_page("controls")))
|
||||
return v
|
||||
|
||||
func _go_hub() -> void:
|
||||
close_menu()
|
||||
MapLoader.enter("hub", HUB_SCENE, "", "Хаб")
|
||||
|
||||
func _open_lb() -> void:
|
||||
close_menu()
|
||||
Overlay.open_lb()
|
||||
|
||||
func _open_nick() -> void:
|
||||
open = false
|
||||
_guard = REOPEN_GUARD
|
||||
_layer.visible = false
|
||||
get_viewport().gui_release_focus()
|
||||
Overlay.open_nick(false) # сама держит Net.input_blocked и мышь
|
||||
|
||||
# ---------- настройки ----------
|
||||
|
||||
func _build_settings() -> Control:
|
||||
var v := _vbox()
|
||||
_add_slider(v, "mouse", "Чувствительность мыши", 0.2, 3.0, 0.05, func(x): Settings.mouse_sens = x, func(x): return "×%.2f" % x)
|
||||
_add_slider(v, "stick", "Чувствительность стика", 0.3, 2.5, 0.05, func(x): Settings.stick_sens = x, func(x): return "×%.2f" % x)
|
||||
_add_slider(v, "fov", "Поле зрения", 60.0, 100.0, 1.0, func(x): Settings.fov = x, func(x): return "%d°" % int(x))
|
||||
_add_slider(v, "volume", "Громкость", 0.0, 1.0, 0.01, func(x): Settings.volume = x, func(x): return "%d%%" % int(round(x * 100.0)))
|
||||
_invert = CheckButton.new()
|
||||
_invert.text = "Инвертировать вертикаль"
|
||||
_invert.focus_mode = Control.FOCUS_ALL
|
||||
_invert.toggled.connect(func(on: bool): Settings.invert_y = on)
|
||||
v.add_child(_invert)
|
||||
v.add_child(_button("По умолчанию", func():
|
||||
Settings.reset_defaults()
|
||||
_refresh_settings()))
|
||||
v.add_child(_button("Назад", func(): _show_page("main")))
|
||||
return v
|
||||
|
||||
func _add_slider(parent: Control, key: String, caption: String, lo: float, hi: float, step: float, setter: Callable, fmt: Callable) -> void:
|
||||
var row := VBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 2)
|
||||
var head := HBoxContainer.new()
|
||||
var name_l := LbView.make_label(caption, 18, TEXT)
|
||||
name_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
head.add_child(name_l)
|
||||
var val_l := LbView.make_label("", 18, MINE)
|
||||
head.add_child(val_l)
|
||||
row.add_child(head)
|
||||
var s := HSlider.new()
|
||||
s.min_value = lo
|
||||
s.max_value = hi
|
||||
s.step = step
|
||||
s.focus_mode = Control.FOCUS_ALL
|
||||
s.custom_minimum_size = Vector2(0, 26)
|
||||
s.value_changed.connect(func(x: float):
|
||||
setter.call(x)
|
||||
val_l.text = fmt.call(x)
|
||||
Settings.apply())
|
||||
row.add_child(s)
|
||||
parent.add_child(row)
|
||||
_sliders[key] = {"slider": s, "label": val_l, "fmt": fmt}
|
||||
|
||||
func _refresh_settings() -> void:
|
||||
var values := {"mouse": Settings.mouse_sens, "stick": Settings.stick_sens, "fov": Settings.fov, "volume": Settings.volume}
|
||||
for k in values:
|
||||
var e: Dictionary = _sliders[k]
|
||||
e.slider.set_value_no_signal(values[k])
|
||||
e.label.text = e.fmt.call(values[k])
|
||||
_invert.set_pressed_no_signal(Settings.invert_y)
|
||||
|
||||
# ---------- справка по управлению ----------
|
||||
|
||||
func _build_controls() -> Control:
|
||||
var v := _vbox()
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 3
|
||||
grid.add_theme_constant_override("h_separation", 22)
|
||||
grid.add_theme_constant_override("v_separation", 5)
|
||||
var rows := [
|
||||
["", "Клавиатура", "Геймпад Xbox"],
|
||||
["Движение / обзор", "WASD / мышь", "левый / правый стик"],
|
||||
["Прыжок, подтягивание", "Пробел", "A или LB"],
|
||||
["Спринт", "Shift", "RT или L3"],
|
||||
["Рывок", "Q", "X или RB"],
|
||||
["Присед (держать)", "C", "B"],
|
||||
["Подкат (на бегу)", "Shift + C", "RT + B"],
|
||||
["Зум", "ЛКМ", "LT"],
|
||||
["«Готов» / забег заново", "R", "Y"],
|
||||
["Сдаться в гонке (держать)", "G", "крестовина вниз"],
|
||||
["Таблица лидеров", "Tab", "Back"],
|
||||
["Ник", "N", "в меню паузы"],
|
||||
["Пауза", "Esc", "Start"],
|
||||
]
|
||||
for r in rows.size():
|
||||
for c in 3:
|
||||
var l := LbView.make_label(rows[r][c], 16, MINE if r == 0 else (TEXT if c == 0 else DIM))
|
||||
grid.add_child(l)
|
||||
v.add_child(grid)
|
||||
var tip := LbView.make_label("Стены: прыжок в воздухе у стены с зажатым «вперёд» — бег по стене.", 14, DIM)
|
||||
v.add_child(tip)
|
||||
v.add_child(_button("Назад", func(): _show_page("main")))
|
||||
return v
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyvtp5hwd4h5o
|
||||
@@ -0,0 +1,147 @@
|
||||
extends Node
|
||||
# Автолоад «Settings»: пользовательские настройки (мышь, стик, FOV, громкость) и раскладка геймпада Xbox.
|
||||
# Геймпад добавляется в InputMap кодом (а не в project.godot): раскладка собрана в одном месте и видна целиком.
|
||||
# Сохраняется в user://settings.cfg, но только когда игрок что-то менял (save()).
|
||||
#
|
||||
# Раскладка под паркур и гонки (правый большой палец всегда остаётся на стике обзора):
|
||||
# левый стик — движение, правый стик — обзор
|
||||
# A / LB — прыжок (подтягивание, прыжок от стены, двойной прыжок) RT / L3 — спринт (держать)
|
||||
# X / RB — рывок B — присед (держать); RT + B на бегу — подкат
|
||||
# LT / R3 — зум Y — «готов» (гонка) / забег заново (Parkour)
|
||||
# Back — таблица лидеров крестовина вниз (держать) — сдаться в гонке
|
||||
# Start — пауза
|
||||
|
||||
signal changed
|
||||
|
||||
const PATH := "user://settings.cfg"
|
||||
|
||||
const DEFAULT_FOV := 60.0
|
||||
const STICK_DEADZONE := 0.22
|
||||
const STICK_LOOK_SPEED := 3.6 # рад/с при полном отклонении стика и множителе 1.0 (~205°/с)
|
||||
|
||||
var mouse_sens := 1.0 # множитель к player.MOUSE_SENSITIVITY
|
||||
var stick_sens := 1.0 # множитель к STICK_LOOK_SPEED
|
||||
var fov := DEFAULT_FOV # базовое поле зрения
|
||||
var volume := 1.0 # общая громкость 0..1
|
||||
var invert_y := false # инверсия вертикали (мышь и стик)
|
||||
|
||||
var gamepad_used := false # игрок трогал геймпад (для подсказок)
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_setup_gamepad()
|
||||
load_settings()
|
||||
apply()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventJoypadButton or (event is InputEventJoypadMotion and absf(event.axis_value) > 0.5):
|
||||
gamepad_used = true
|
||||
elif event is InputEventKey or event is InputEventMouseButton:
|
||||
gamepad_used = false
|
||||
|
||||
# ---------- сохранение ----------
|
||||
|
||||
func load_settings() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PATH) != OK:
|
||||
return
|
||||
mouse_sens = clampf(float(cfg.get_value("input", "mouse_sens", mouse_sens)), 0.2, 3.0)
|
||||
stick_sens = clampf(float(cfg.get_value("input", "stick_sens", stick_sens)), 0.3, 2.5)
|
||||
invert_y = bool(cfg.get_value("input", "invert_y", invert_y))
|
||||
fov = clampf(float(cfg.get_value("video", "fov", fov)), 60.0, 100.0)
|
||||
volume = clampf(float(cfg.get_value("audio", "volume", volume)), 0.0, 1.0)
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("input", "mouse_sens", mouse_sens)
|
||||
cfg.set_value("input", "stick_sens", stick_sens)
|
||||
cfg.set_value("input", "invert_y", invert_y)
|
||||
cfg.set_value("video", "fov", fov)
|
||||
cfg.set_value("audio", "volume", volume)
|
||||
cfg.save(PATH)
|
||||
|
||||
func apply() -> void:
|
||||
var bus := AudioServer.get_bus_index("Master")
|
||||
AudioServer.set_bus_mute(bus, volume <= 0.001)
|
||||
AudioServer.set_bus_volume_db(bus, linear_to_db(maxf(volume, 0.001)))
|
||||
changed.emit()
|
||||
|
||||
func reset_defaults() -> void:
|
||||
mouse_sens = 1.0
|
||||
stick_sens = 1.0
|
||||
fov = DEFAULT_FOV
|
||||
volume = 1.0
|
||||
invert_y = false
|
||||
apply()
|
||||
|
||||
# ---------- геймпад ----------
|
||||
|
||||
func _setup_gamepad() -> void:
|
||||
# Стики: двигаем и смотрим
|
||||
_axis("move_left", JOY_AXIS_LEFT_X, -1.0)
|
||||
_axis("move_right", JOY_AXIS_LEFT_X, 1.0)
|
||||
_axis("move_forward", JOY_AXIS_LEFT_Y, -1.0)
|
||||
_axis("move_back", JOY_AXIS_LEFT_Y, 1.0)
|
||||
for a in ["move_left", "move_right", "move_forward", "move_back"]:
|
||||
InputMap.action_set_deadzone(a, STICK_DEADZONE)
|
||||
_new_action("look_left", STICK_DEADZONE)
|
||||
_new_action("look_right", STICK_DEADZONE)
|
||||
_new_action("look_up", STICK_DEADZONE)
|
||||
_new_action("look_down", STICK_DEADZONE)
|
||||
_axis("look_left", JOY_AXIS_RIGHT_X, -1.0)
|
||||
_axis("look_right", JOY_AXIS_RIGHT_X, 1.0)
|
||||
_axis("look_up", JOY_AXIS_RIGHT_Y, -1.0)
|
||||
_axis("look_down", JOY_AXIS_RIGHT_Y, 1.0)
|
||||
|
||||
# Действия
|
||||
_button("jump", JOY_BUTTON_A)
|
||||
_button("jump", JOY_BUTTON_LEFT_SHOULDER) # прыжок на плече: правый палец не уходит со стика обзора
|
||||
_axis("sprint", JOY_AXIS_TRIGGER_RIGHT, 1.0)
|
||||
_button("sprint", JOY_BUTTON_LEFT_STICK)
|
||||
_button("crouch", JOY_BUTTON_B)
|
||||
_button("dash", JOY_BUTTON_X)
|
||||
_button("dash", JOY_BUTTON_RIGHT_SHOULDER)
|
||||
_new_action("zoom", 0.3)
|
||||
_axis("zoom", JOY_AXIS_TRIGGER_LEFT, 1.0)
|
||||
_button("zoom", JOY_BUTTON_RIGHT_STICK)
|
||||
_button("ready", JOY_BUTTON_Y)
|
||||
_button("leaderboard", JOY_BUTTON_BACK)
|
||||
_button("giveup", JOY_BUTTON_DPAD_DOWN)
|
||||
|
||||
# Навигация по меню: A — выбрать, B — назад (в этом проекте у ui_accept/ui_cancel кнопок геймпада нет)
|
||||
_button_once("ui_accept", JOY_BUTTON_A)
|
||||
_button_once("ui_cancel", JOY_BUTTON_B)
|
||||
_button_once("ui_up", JOY_BUTTON_DPAD_UP)
|
||||
_button_once("ui_down", JOY_BUTTON_DPAD_DOWN)
|
||||
_button_once("ui_left", JOY_BUTTON_DPAD_LEFT)
|
||||
_button_once("ui_right", JOY_BUTTON_DPAD_RIGHT)
|
||||
|
||||
# Пауза: Esc и Start
|
||||
_new_action("pause", 0.5)
|
||||
var esc := InputEventKey.new()
|
||||
esc.physical_keycode = KEY_ESCAPE
|
||||
InputMap.action_add_event("pause", esc)
|
||||
_button("pause", JOY_BUTTON_START)
|
||||
|
||||
func _new_action(action: String, deadzone: float) -> void:
|
||||
if not InputMap.has_action(action):
|
||||
InputMap.add_action(action, deadzone)
|
||||
|
||||
func _axis(action: String, axis: JoyAxis, value: float) -> void:
|
||||
_new_action(action, 0.5)
|
||||
var e := InputEventJoypadMotion.new()
|
||||
e.axis = axis
|
||||
e.axis_value = value
|
||||
InputMap.action_add_event(action, e)
|
||||
|
||||
func _button_once(action: String, button: JoyButton) -> void:
|
||||
for e in InputMap.action_get_events(action):
|
||||
if e is InputEventJoypadButton and e.button_index == button:
|
||||
return
|
||||
_button(action, button)
|
||||
|
||||
func _button(action: String, button: JoyButton) -> void:
|
||||
_new_action(action, 0.5)
|
||||
var e := InputEventJoypadButton.new()
|
||||
e.button_index = button
|
||||
InputMap.action_add_event(action, e)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkyvk0c3jox51
|
||||
+51
-1
@@ -10,7 +10,7 @@ dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter="tools/*, maps/sandbox/*, maps/backrooms/*, maps/parkour/*, maps/race/*, chasers/rock/*, chasers/verity/*"
|
||||
exclude_filter="tools/*, maps/sandbox/*, maps/backrooms/*, maps/parkour/*, maps/race/*, maps/race2/*, chasers/rock/*, chasers/verity/*"
|
||||
export_path="../grid-world/index.html"
|
||||
patches=PackedStringArray()
|
||||
patch_delta_encoding=false
|
||||
@@ -250,3 +250,53 @@ progressive_web_app/icon_512x512=""
|
||||
progressive_web_app/background_color=Color(0, 0, 0, 1)
|
||||
threads/emscripten_pool_size=8
|
||||
threads/godot_pool_size=4
|
||||
|
||||
[preset.5]
|
||||
|
||||
name="Race2"
|
||||
platform="Web"
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="resources"
|
||||
export_files=PackedStringArray("res://maps/race2/race2.tscn")
|
||||
include_filter="maps/race2/sky.png, maps/race2/baked.gdshader, maps/race2/race2_data.gd, maps/race/race_rules.gd, maps/race2/race2.gd, maps/race2/audio/*"
|
||||
exclude_filter="player/*, objects/*, chasers/chaser.gd"
|
||||
export_path="../grid-world/race2.pck"
|
||||
patches=PackedStringArray()
|
||||
patch_delta_encoding=false
|
||||
patch_delta_compression_level_zstd=19
|
||||
patch_delta_min_reduction=0.1
|
||||
patch_delta_include_filters="*"
|
||||
patch_delta_exclude_filters=""
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
seed=0
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_export_mode=2
|
||||
|
||||
[preset.5.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
variant/extensions_support=false
|
||||
variant/thread_support=false
|
||||
vram_texture_compression/for_desktop=true
|
||||
vram_texture_compression/for_mobile=false
|
||||
html/export_icon=true
|
||||
html/custom_html_shell=""
|
||||
html/head_include=""
|
||||
html/canvas_resize_policy=2
|
||||
html/focus_canvas_on_start=true
|
||||
html/experimental_virtual_keyboard=false
|
||||
progressive_web_app/enabled=false
|
||||
progressive_web_app/ensure_cross_origin_isolation_headers=true
|
||||
progressive_web_app/offline_page=""
|
||||
progressive_web_app/display=3
|
||||
progressive_web_app/orientation=0
|
||||
progressive_web_app/icon_144x144=""
|
||||
progressive_web_app/icon_180x180=""
|
||||
progressive_web_app/icon_512x512=""
|
||||
progressive_web_app/background_color=Color(0, 0, 0, 1)
|
||||
threads/emscripten_pool_size=8
|
||||
threads/godot_pool_size=4
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Музыка хаба
|
||||
|
||||
«Local Forecast - Elevator» — Kevin MacLeod (incompetech.com)
|
||||
Лицензия: Creative Commons: By Attribution (CC BY) — http://creativecommons.org/licenses/by/4.0/
|
||||
Источник: https://incompetech.com/music/royalty-free/mp3-royaltyfree/Local%20Forecast%20-%20Elevator.mp3
|
||||
Обработка: моно, 32 кГц, Ogg Vorbis q1 (`ffmpeg -i исходник.mp3 -vn -map_metadata -1 -ac 1 -ar 32000 -c:a libvorbis -q:a 1 music.ogg`).
|
||||
При публикации игры нужно указывать автора.
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://ch6afg8c4xjr4"
|
||||
path="res://.godot/imported/music.ogg-07da3df5f1653a19addb3d04617388e7.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://maps/hub/audio/music.ogg"
|
||||
dest_files=["res://.godot/imported/music.ogg-07da3df5f1653a19addb3d04617388e7.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
+147
-14
@@ -1,20 +1,26 @@
|
||||
extends Node3D
|
||||
# Стартовая комната: ничего тяжёлого, только пол, стены и порталы на карты. Сами карты качаются
|
||||
# отдельными пакетами при входе в портал (World/map_loader.gd) — поэтому игра стартует быстро.
|
||||
# Чтобы добавить карту — допиши запись в PORTALS: порталы встанут в ряд по центру (влезает 6).
|
||||
# Чтобы добавить карту — допиши запись в PORTALS: порталы встанут в ряд по центру (влезает 7).
|
||||
# Оформление (колонны, окна, голограмма, лавки) — hub_decor.gd; музыка «как в лифте» играет по кругу.
|
||||
|
||||
const PORTALS := [
|
||||
{"title": "SANDBOX", "map": "world", "scene": "res://maps/sandbox/sandbox.tscn", "pack": "world.pck", "color": Color(0.2, 0.9, 1.0)},
|
||||
{"title": "BACKROOMS", "map": "backrooms", "scene": "res://maps/backrooms/backrooms.tscn", "pack": "backrooms.pck", "color": Color(1.0, 0.85, 0.25)},
|
||||
{"title": "PARKOUR", "map": "parkour", "scene": "res://maps/parkour/parkour.tscn", "pack": "parkour.pck", "color": Color(1.0, 0.2, 0.65)},
|
||||
{"title": "RACE", "map": "race", "scene": "res://maps/race/race.tscn", "pack": "race.pck", "color": Color(1.0, 0.55, 0.12)},
|
||||
{"title": "RACE 2", "map": "race2", "scene": "res://maps/race2/race2.tscn", "pack": "race2.pck", "color": Color(0.45, 0.85, 0.35)},
|
||||
]
|
||||
|
||||
const ROOM_WIDTH := 28.0
|
||||
const ROOM_DEPTH := 18.0
|
||||
const ROOM_HEIGHT := 6.0
|
||||
const ROOM_WIDTH := 48.0
|
||||
const ROOM_DEPTH := 32.0
|
||||
const ROOM_HEIGHT := 10.0 # над порталами доски рейтинга, над ними — «CHOOSE A MAP»
|
||||
const WALL := 0.5
|
||||
const PORTAL_SPACING := 4.5
|
||||
const PORTAL_SPACING := 6.0
|
||||
|
||||
const MUSIC_PATH := "res://maps/hub/audio/music.ogg"
|
||||
const MUSIC_DB := -9.0
|
||||
const DecorScript := preload("res://maps/hub/hub_decor.gd")
|
||||
|
||||
const GRID_SHADER := """
|
||||
shader_type spatial;
|
||||
@@ -35,17 +41,25 @@ func _ready() -> void:
|
||||
_build_room()
|
||||
_build_portals()
|
||||
_build_environment()
|
||||
_build_boards()
|
||||
_build_decor()
|
||||
_start_music()
|
||||
if not Net.nick_asked:
|
||||
Overlay.open_nick.call_deferred(true) # первый запуск: спрашиваем ник
|
||||
|
||||
func _build_room() -> void:
|
||||
var body := StaticBody3D.new()
|
||||
body.name = "Room"
|
||||
add_child(body)
|
||||
|
||||
var floor_mat := ShaderMaterial.new()
|
||||
floor_mat.shader = Shader.new()
|
||||
floor_mat.shader.code = GRID_SHADER
|
||||
var decor := DecorScript.new()
|
||||
decor.name = "Decor"
|
||||
add_child(decor)
|
||||
var floor_mat := decor.floor_material()
|
||||
var wall_mat := StandardMaterial3D.new()
|
||||
wall_mat.albedo_color = Color(0.09, 0.1, 0.14)
|
||||
wall_mat.albedo_color = Color(0.12, 0.13, 0.18)
|
||||
wall_mat.metallic = 0.3
|
||||
wall_mat.roughness = 0.6
|
||||
wall_mat.roughness = 1.0
|
||||
var ceiling_mat := StandardMaterial3D.new()
|
||||
ceiling_mat.albedo_color = Color(0.05, 0.055, 0.08)
|
||||
@@ -98,24 +112,143 @@ func _build_portals() -> void:
|
||||
label.font_size = 64
|
||||
label.outline_size = 12
|
||||
label.modulate = Color(0.7, 0.9, 1.0)
|
||||
label.position = Vector3(0, ROOM_HEIGHT - 0.35, -ROOM_DEPTH * 0.5 + 0.1)
|
||||
label.position = Vector3(0, 7.2, -ROOM_DEPTH * 0.5 + 0.1)
|
||||
label.pixel_size = 0.016
|
||||
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
|
||||
env.background_color = Color(0.02, 0.025, 0.04)
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.5, 0.58, 0.75)
|
||||
env.ambient_light_energy = 0.55
|
||||
env.ambient_light_energy = 1.0
|
||||
env.glow_enabled = true # неон и светильники светятся (в Compatibility glow работает)
|
||||
env.glow_intensity = 0.8
|
||||
env.glow_bloom = 0.1
|
||||
var we := WorldEnvironment.new()
|
||||
we.environment = env
|
||||
add_child(we)
|
||||
|
||||
var lamp := OmniLight3D.new()
|
||||
lamp.position = Vector3(0, ROOM_HEIGHT - 0.6, 2.0)
|
||||
lamp.omni_range = 22.0
|
||||
lamp.light_energy = 0.8
|
||||
lamp.position = Vector3(0, ROOM_HEIGHT - 0.8, -6.0)
|
||||
lamp.omni_range = 30.0
|
||||
lamp.light_energy = 0.9
|
||||
lamp.light_color = Color(0.8, 0.9, 1.0)
|
||||
lamp.shadow_enabled = false
|
||||
add_child(lamp)
|
||||
|
||||
# ---------- оформление и музыка ----------
|
||||
|
||||
func _build_decor() -> void:
|
||||
var decor := get_node("Decor")
|
||||
decor.build(ROOM_WIDTH, ROOM_DEPTH, ROOM_HEIGHT)
|
||||
|
||||
func _start_music() -> void:
|
||||
var stream = load(MUSIC_PATH)
|
||||
if stream == null:
|
||||
return
|
||||
if stream is AudioStreamOggVorbis:
|
||||
stream.loop = true
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.name = "Music"
|
||||
player.stream = stream
|
||||
player.volume_db = -40.0
|
||||
player.autoplay = true
|
||||
add_child(player)
|
||||
create_tween().tween_property(player, "volume_db", MUSIC_DB, 3.0) # плавно нарастает
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
script = ExtResource("1_hub")
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("2_player")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 5)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 11)
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
extends Node3D
|
||||
# Оформление хаба: пол с кольцами, колонны, потолочные светильники, окна с ночным городом, лавки,
|
||||
# платформа с вращающейся голограммой в центре, пылинки в воздухе. Размеры берутся из hub.gd.
|
||||
# Всё строится кодом и без тяжёлых ресурсов — хаб лежит в основном пакете игры.
|
||||
|
||||
const BRASS := Color(0.85, 0.58, 0.26)
|
||||
const CYAN := Color(0.25, 0.85, 1.0)
|
||||
const DARK := Color(0.045, 0.05, 0.065)
|
||||
|
||||
const FLOOR_SHADER := """
|
||||
shader_type spatial;
|
||||
varying vec3 wpos;
|
||||
void vertex() { wpos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; }
|
||||
void fragment() {
|
||||
vec2 cell = wpos.xz / 2.0;
|
||||
vec2 g = abs(fract(cell - 0.5) - 0.5) / fwidth(cell);
|
||||
float line = 1.0 - min(min(g.x, g.y), 1.0);
|
||||
float r = length(wpos.xz);
|
||||
float ring = 1.0 - smoothstep(0.0, 0.09, min(abs(r - 7.5), abs(r - 12.5)));
|
||||
// дорожка света от платформы к порталам
|
||||
float lane = 1.0 - smoothstep(0.0, 0.07, abs(wpos.x)) ;
|
||||
lane *= step(wpos.z, -4.0) * 0.8;
|
||||
vec3 base = vec3(0.028, 0.032, 0.045);
|
||||
vec3 col = mix(base, vec3(0.08, 0.5, 0.65), line * 0.55);
|
||||
col = mix(col, vec3(0.9, 0.58, 0.24), clamp(ring + lane, 0.0, 1.0));
|
||||
ALBEDO = col * 0.55;
|
||||
EMISSION = vec3(0.03, 0.25, 0.34) * line + vec3(0.5, 0.3, 0.1) * clamp(ring + lane, 0.0, 1.0);
|
||||
ROUGHNESS = 0.35;
|
||||
METALLIC = 0.4;
|
||||
}
|
||||
"""
|
||||
|
||||
# Окно: ночной город — небо с зарёй у горизонта, силуэты домов со светящимися окнами
|
||||
const CITY_SHADER := """
|
||||
shader_type spatial;
|
||||
render_mode unshaded;
|
||||
float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||
void fragment() {
|
||||
float yb = 1.0 - UV.y;
|
||||
vec3 col = mix(vec3(0.015, 0.02, 0.06), vec3(0.42, 0.14, 0.30), pow(yb, 2.2) * 0.9);
|
||||
col += vec3(0.5, 0.25, 0.1) * smoothstep(0.35, 0.0, yb) * 0.6;
|
||||
for (int layer = 0; layer < 2; layer++) {
|
||||
float n = layer == 0 ? 14.0 : 24.0;
|
||||
float id = floor(UV.x * n + float(layer) * 7.0);
|
||||
float top = (layer == 0 ? 0.42 : 0.26) * (0.35 + 0.65 * h(vec2(id, float(layer) + 1.0)));
|
||||
if (yb < top) {
|
||||
vec3 body = layer == 0 ? vec3(0.02, 0.02, 0.035) : vec3(0.05, 0.03, 0.07);
|
||||
vec2 wc = vec2(UV.x * n * 3.0, yb * (layer == 0 ? 34.0 : 22.0));
|
||||
vec2 wi = floor(wc);
|
||||
vec2 wf = fract(wc);
|
||||
float lit = step(0.62, h(wi + id)) * step(0.2, wf.x) * step(wf.x, 0.8) * step(0.25, wf.y) * step(wf.y, 0.75);
|
||||
vec3 wcol = h(wi + 3.0) > 0.55 ? vec3(1.0, 0.7, 0.3) : vec3(0.4, 0.8, 1.0);
|
||||
col = body + wcol * lit * (layer == 0 ? 0.9 : 0.5);
|
||||
break;
|
||||
}
|
||||
}
|
||||
ALBEDO = col;
|
||||
}
|
||||
"""
|
||||
|
||||
var _spin: Array = [] # [узел, ось, скорость]
|
||||
var _pulse_mat: StandardMaterial3D
|
||||
var _t := 0.0
|
||||
|
||||
func build(width: float, depth: float, height: float) -> void:
|
||||
_build_pillars(width, depth, height)
|
||||
_build_ceiling(width, depth, height)
|
||||
_build_windows(width, depth, height)
|
||||
_build_benches(width, depth)
|
||||
_build_dais()
|
||||
_build_signs(width, depth, height)
|
||||
_build_dust(width, depth, height)
|
||||
|
||||
func floor_material() -> ShaderMaterial:
|
||||
var m := ShaderMaterial.new()
|
||||
m.shader = Shader.new()
|
||||
m.shader.code = FLOOR_SHADER
|
||||
return m
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_t += delta
|
||||
for s in _spin:
|
||||
(s[0] as Node3D).rotate(s[1], s[2] * delta)
|
||||
if _pulse_mat:
|
||||
_pulse_mat.emission_energy_multiplier = 1.6 + 0.5 * sin(_t * 1.6)
|
||||
|
||||
# ---------- вспомогательное ----------
|
||||
|
||||
func _emissive(c: Color, energy := 2.0) -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = c.darkened(0.6)
|
||||
m.emission_enabled = true
|
||||
m.emission = c
|
||||
m.emission_energy_multiplier = energy
|
||||
return m
|
||||
|
||||
func _matte(c: Color, metallic := 0.0, rough := 0.7) -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = c
|
||||
m.metallic = metallic
|
||||
m.roughness = rough
|
||||
return m
|
||||
|
||||
func _box(size: Vector3, pos: Vector3, mat: Material, collide := false) -> MeshInstance3D:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
mesh.material = mat
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(mi)
|
||||
if collide:
|
||||
var body := StaticBody3D.new()
|
||||
var cs := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = size
|
||||
cs.shape = shape
|
||||
body.add_child(cs)
|
||||
mi.add_child(body)
|
||||
return mi
|
||||
|
||||
func _label(text: String, size: float, color: Color, pos: Vector3, yaw := 0.0) -> Label3D:
|
||||
var l := Label3D.new()
|
||||
l.text = text
|
||||
l.font_size = 64
|
||||
l.pixel_size = size
|
||||
l.outline_size = 10
|
||||
l.modulate = color
|
||||
l.shaded = false
|
||||
l.position = pos
|
||||
l.rotation.y = yaw
|
||||
add_child(l)
|
||||
return l
|
||||
|
||||
# ---------- колонны ----------
|
||||
|
||||
func _build_pillars(width: float, depth: float, height: float) -> void:
|
||||
var steel := _matte(Color(0.07, 0.075, 0.09), 0.6, 0.4)
|
||||
var glow := _emissive(CYAN, 2.2)
|
||||
var brass := _emissive(BRASS, 1.4)
|
||||
var px := width * 0.5 - 8.0
|
||||
for sx in [-1.0, 1.0]:
|
||||
for z in [-6.0, 2.0, 10.0]:
|
||||
var p := Vector3(sx * px, 0.0, z)
|
||||
_box(Vector3(1.1, height, 1.1), p + Vector3(0, height * 0.5, 0), steel, true)
|
||||
_box(Vector3(1.2, 0.18, 1.2), p + Vector3(0, 0.09, 0), brass)
|
||||
_box(Vector3(1.2, 0.18, 1.2), p + Vector3(0, height - 0.09, 0), brass)
|
||||
# светящаяся прорезь на каждой грани по высоте
|
||||
_box(Vector3(0.12, height - 1.6, 1.14), p + Vector3(0, height * 0.5, 0), glow)
|
||||
_box(Vector3(1.14, height - 1.6, 0.12), p + Vector3(0, height * 0.5, 0), glow)
|
||||
|
||||
# ---------- потолок ----------
|
||||
|
||||
func _build_ceiling(width: float, depth: float, height: float) -> void:
|
||||
var strip := _emissive(Color(1.0, 0.86, 0.62), 2.4)
|
||||
var y := height - 0.08
|
||||
for x in [-18.0, -9.0, 0.0, 9.0, 18.0]:
|
||||
_box(Vector3(0.35, 0.12, depth - 4.0), Vector3(x, y, 0), strip)
|
||||
for z in [-8.0, 0.0, 8.0]:
|
||||
_box(Vector3(width - 4.0, 0.1, 0.22), Vector3(0, y + 0.02, z), _emissive(BRASS, 1.2))
|
||||
# большое кольцо над платформой
|
||||
var ring := TorusMesh.new()
|
||||
ring.inner_radius = 3.9
|
||||
ring.outer_radius = 4.1
|
||||
ring.material = _emissive(CYAN, 2.0)
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = ring
|
||||
mi.position = Vector3(0, height - 0.5, 0)
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(mi)
|
||||
# мягкий свет зон: тёплый у порталов, холодный в центре
|
||||
for x in [-14.0, 14.0]:
|
||||
var l := OmniLight3D.new()
|
||||
l.position = Vector3(x, height - 1.0, 4.0)
|
||||
l.omni_range = 20.0
|
||||
l.light_energy = 0.7
|
||||
l.light_color = Color(1.0, 0.82, 0.6)
|
||||
l.shadow_enabled = false
|
||||
add_child(l)
|
||||
|
||||
# ---------- окна в боковых стенах ----------
|
||||
|
||||
func _build_windows(width: float, depth: float, height: float) -> void:
|
||||
var city := ShaderMaterial.new()
|
||||
city.shader = Shader.new()
|
||||
city.shader.code = CITY_SHADER
|
||||
var frame := _matte(Color(0.05, 0.05, 0.06), 0.7, 0.35)
|
||||
var brass := _emissive(BRASS, 0.9)
|
||||
var hw := width * 0.5
|
||||
var win := Vector2(8.5, 5.6)
|
||||
var y := 4.6
|
||||
for side in [-1, 1]:
|
||||
var yaw := PI * 0.5 * float(-side) # лицом в комнату
|
||||
var x := float(side) * (hw - 0.04)
|
||||
for z in [-8.5, 0.5, 9.5]:
|
||||
var q := QuadMesh.new()
|
||||
q.size = win
|
||||
q.material = city
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = q
|
||||
mi.position = Vector3(x, y, z)
|
||||
mi.rotation.y = yaw
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(mi)
|
||||
# рама и переплёты
|
||||
var inner_x := float(side) * (hw - 0.12)
|
||||
_box(Vector3(0.1, win.y, 0.18), Vector3(inner_x, y, z - win.x * 0.25), frame)
|
||||
_box(Vector3(0.1, win.y, 0.18), Vector3(inner_x, y, z + win.x * 0.25), frame)
|
||||
_box(Vector3(0.1, 0.18, win.x), Vector3(inner_x, y - 0.5, z), frame)
|
||||
for e in [-1.0, 1.0]:
|
||||
_box(Vector3(0.12, win.y + 0.5, 0.2), Vector3(inner_x, y, z + e * (win.x * 0.5 + 0.1)), frame)
|
||||
var wl := OmniLight3D.new() # отсвет города на полу и лавках
|
||||
wl.position = Vector3(float(side) * (hw - 3.0), y - 1.0, z)
|
||||
wl.light_color = Color(0.75, 0.45, 0.9)
|
||||
wl.light_energy = 1.1
|
||||
wl.omni_range = 11.0
|
||||
wl.shadow_enabled = false
|
||||
add_child(wl)
|
||||
_box(Vector3(0.12, 0.08, win.x + 0.5), Vector3(inner_x, y - win.y * 0.5 - 0.3, z), brass)
|
||||
_box(Vector3(0.12, 0.08, win.x + 0.5), Vector3(inner_x, y + win.y * 0.5 + 0.3, z), brass)
|
||||
|
||||
# ---------- лавки ----------
|
||||
|
||||
func _build_benches(width: float, _depth: float) -> void:
|
||||
var seat := _matte(Color(0.16, 0.08, 0.06), 0.0, 0.55)
|
||||
var metal := _matte(Color(0.08, 0.08, 0.1), 0.8, 0.3)
|
||||
var x := width * 0.5 - 2.0
|
||||
for side in [-1.0, 1.0]:
|
||||
for z in [-8.5, 0.5, 9.5]:
|
||||
var base := Vector3(side * x, 0.0, z)
|
||||
_box(Vector3(1.0, 0.45, 5.0), base + Vector3(0, 0.225, 0), seat, true)
|
||||
_box(Vector3(0.25, 0.9, 5.0), base + Vector3(side * 0.5, 0.7, 0), seat)
|
||||
_box(Vector3(0.95, 0.08, 4.8), base + Vector3(0, 0.02, 0), metal)
|
||||
_box(Vector3(0.05, 0.04, 4.8), base + Vector3(-side * 0.5, 0.3, 0), _emissive(BRASS, 1.0))
|
||||
|
||||
# ---------- центр: платформа и голограмма ----------
|
||||
|
||||
func _build_dais() -> void:
|
||||
var body := StaticBody3D.new()
|
||||
add_child(body)
|
||||
var cyl := CylinderMesh.new()
|
||||
cyl.top_radius = 3.6
|
||||
cyl.bottom_radius = 3.7
|
||||
cyl.height = 0.32
|
||||
cyl.material = _matte(Color(0.06, 0.065, 0.08), 0.7, 0.3)
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = cyl
|
||||
mi.position.y = 0.16
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
body.add_child(mi)
|
||||
var cs := CollisionShape3D.new()
|
||||
var shape := CylinderShape3D.new()
|
||||
shape.radius = 3.65
|
||||
shape.height = 0.32
|
||||
cs.shape = shape
|
||||
cs.position.y = 0.16
|
||||
body.add_child(cs)
|
||||
var rim := TorusMesh.new()
|
||||
rim.inner_radius = 3.45
|
||||
rim.outer_radius = 3.6
|
||||
rim.material = _emissive(BRASS, 1.8)
|
||||
var rim_mi := MeshInstance3D.new()
|
||||
rim_mi.mesh = rim
|
||||
rim_mi.position.y = 0.33
|
||||
rim_mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(rim_mi)
|
||||
|
||||
_pulse_mat = _emissive(CYAN, 2.0)
|
||||
var holo := Node3D.new()
|
||||
holo.position = Vector3(0, 2.6, 0)
|
||||
add_child(holo)
|
||||
var core := SphereMesh.new()
|
||||
core.radius = 0.5
|
||||
core.height = 1.0
|
||||
core.material = _pulse_mat
|
||||
var core_mi := MeshInstance3D.new()
|
||||
core_mi.mesh = core
|
||||
core_mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
holo.add_child(core_mi)
|
||||
var specs := [[1.1, CYAN, Vector3(1, 0, 0), 0.7], [1.5, BRASS, Vector3(0, 0, 1), -0.5], [1.9, CYAN, Vector3(0, 1, 0), 0.35]]
|
||||
for i in specs.size():
|
||||
var t := TorusMesh.new()
|
||||
t.inner_radius = specs[i][0] - 0.035
|
||||
t.outer_radius = specs[i][0] + 0.035
|
||||
t.rings = 48
|
||||
t.material = _emissive(specs[i][1], 2.2)
|
||||
var pivot := Node3D.new()
|
||||
holo.add_child(pivot)
|
||||
pivot.rotation = Vector3(0.4 * i, 0.9 * i, 0.0)
|
||||
var tm := MeshInstance3D.new()
|
||||
tm.mesh = t
|
||||
tm.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
pivot.add_child(tm)
|
||||
_spin.append([pivot, specs[i][2], specs[i][3]])
|
||||
var light := OmniLight3D.new()
|
||||
light.position = Vector3(0, 2.6, 0)
|
||||
light.light_color = CYAN
|
||||
light.light_energy = 1.4
|
||||
light.omni_range = 11.0
|
||||
light.shadow_enabled = false
|
||||
add_child(light)
|
||||
var l := Label3D.new()
|
||||
l.text = "AMAN'S WORLD"
|
||||
l.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
l.font_size = 64
|
||||
l.pixel_size = 0.009
|
||||
l.outline_size = 12
|
||||
l.modulate = Color(1.0, 0.9, 0.7)
|
||||
l.shaded = false
|
||||
l.position = Vector3(0, 4.9, 0)
|
||||
add_child(l)
|
||||
|
||||
# ---------- вывески ----------
|
||||
|
||||
func _build_signs(width: float, depth: float, height: float) -> void:
|
||||
var hd := depth * 0.5
|
||||
# южная стена: «лифтовая» панель этажа
|
||||
_box(Vector3(10.0, 2.2, 0.15), Vector3(0, 5.0, hd - 0.08), _matte(Color(0.03, 0.03, 0.04), 0.5, 0.4))
|
||||
_box(Vector3(10.3, 0.08, 0.2), Vector3(0, 6.15, hd - 0.1), _emissive(BRASS, 1.2))
|
||||
_box(Vector3(10.3, 0.08, 0.2), Vector3(0, 3.85, hd - 0.1), _emissive(BRASS, 1.2))
|
||||
_label("ЛОББИ", 0.02, Color(1.0, 0.82, 0.5), Vector3(0, 5.4, hd - 0.2), PI)
|
||||
_label("ЭТАЖ 0 · ВСЕ КАРТЫ ВНИЗУ ПО КОРИДОРУ", 0.008, Color(0.7, 0.9, 1.0), Vector3(0, 4.4, hd - 0.2), PI)
|
||||
# северная стена: фриз над порталами
|
||||
_box(Vector3(width - 6.0, 0.12, 0.3), Vector3(0, 8.9, -hd + 0.15), _emissive(BRASS, 1.3))
|
||||
_box(Vector3(width - 6.0, 0.12, 0.3), Vector3(0, 8.0, -hd + 0.15), _emissive(BRASS, 1.3))
|
||||
|
||||
# ---------- пыль в лучах света ----------
|
||||
|
||||
func _build_dust(width: float, depth: float, height: float) -> void:
|
||||
var p := GPUParticles3D.new()
|
||||
p.amount = 140
|
||||
p.lifetime = 14.0
|
||||
p.preprocess = 14.0
|
||||
p.visibility_aabb = AABB(Vector3(-width * 0.5, 0, -depth * 0.5), Vector3(width, height, depth))
|
||||
var pm := ParticleProcessMaterial.new()
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
pm.emission_box_extents = Vector3(width * 0.5 - 1.0, height * 0.5 - 0.5, depth * 0.5 - 1.0)
|
||||
pm.gravity = Vector3.ZERO
|
||||
pm.direction = Vector3(0.2, 1.0, 0.1)
|
||||
pm.spread = 180.0
|
||||
pm.initial_velocity_min = 0.05
|
||||
pm.initial_velocity_max = 0.25
|
||||
p.process_material = pm
|
||||
var q := QuadMesh.new()
|
||||
q.size = Vector2(0.05, 0.05)
|
||||
var m := StandardMaterial3D.new()
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
m.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
m.albedo_color = Color(1.0, 0.9, 0.7, 0.35)
|
||||
q.material = m
|
||||
p.draw_pass_1 = q
|
||||
p.position = Vector3(0, height * 0.5, 0)
|
||||
p.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(p)
|
||||
@@ -0,0 +1 @@
|
||||
uid://or5cgrfhp73l
|
||||
@@ -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:
|
||||
|
||||
+128
-10
@@ -5,9 +5,11 @@ 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")
|
||||
var Data = null # данные карты (race_data.gd / race2_data.gd): берутся у карты в setup(), по умолчанию — Race
|
||||
|
||||
enum Phase { WAIT, COUNTDOWN, RACE, RESULTS }
|
||||
|
||||
@@ -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 карты в рейтинге сервера
|
||||
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,15 @@ 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
|
||||
Data = m.get("data") if m.get("data") != null else load("res://maps/race/race_data.gd")
|
||||
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 +118,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 +133,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 +162,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 +192,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 +208,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 +223,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 +271,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 +295,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 +340,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 +373,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 +400,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 +431,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 +570,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 +618,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 +640,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)
|
||||
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 +668,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 +698,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:
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://c3hge1rwjutk8"
|
||||
path="res://.godot/imported/music.ogg-f9a618edc60fbea7ca697db82c17868a.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://maps/race2/audio/music.ogg"
|
||||
dest_files=["res://.godot/imported/music.ogg-f9a618edc60fbea7ca697db82c17868a.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,42 @@
|
||||
shader_type spatial;
|
||||
// Поверхности уровня Parkour: освещение запечено в цвета вершин (Blender, Cycles), динамических источников нет.
|
||||
// Итог = цвет материала × запечённый свет × мелкое «зерно» бетона (по мировым координатам) + слабый блик на скользящих углах.
|
||||
render_mode unshaded;
|
||||
|
||||
uniform vec3 albedo_color = vec3(0.1);
|
||||
uniform float light_scale = 8.0; // цвета вершин сжаты в 8 раз при экспорте (см. bake.py)
|
||||
uniform float exposure = 1.0;
|
||||
uniform sampler2D grain : repeat_enable, filter_linear_mipmap;
|
||||
uniform float grain_scale = 0.3;
|
||||
uniform float grain_amount = 0.45;
|
||||
uniform sampler2D sky_tex : source_color, filter_linear_mipmap; // панорама неба: отражается в «мокрых» горизонтальных поверхностях
|
||||
uniform float wet = 0.6;
|
||||
|
||||
varying vec3 wpos;
|
||||
varying vec3 wnorm;
|
||||
|
||||
void vertex() {
|
||||
wpos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||
wnorm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
|
||||
}
|
||||
|
||||
vec2 pano_uv(vec3 d) {
|
||||
return vec2(atan(d.x, -d.z) / (2.0 * PI) + 0.5, acos(clamp(d.y, -1.0, 1.0)) / PI);
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec3 n = abs(wnorm);
|
||||
n /= (n.x + n.y + n.z + 0.0001);
|
||||
float g = texture(grain, wpos.zy * grain_scale).r * n.x
|
||||
+ texture(grain, wpos.xz * grain_scale).r * n.y
|
||||
+ texture(grain, wpos.xy * grain_scale).r * n.z;
|
||||
float gr = mix(1.0 - grain_amount * 0.5, 1.0 + grain_amount * 0.5, g);
|
||||
vec3 lit = COLOR.rgb * light_scale;
|
||||
vec3 nv = normalize(NORMAL);
|
||||
float ndv = clamp(dot(nv, normalize(VIEW)), 0.0, 1.0);
|
||||
float fres = 0.05 + 0.95 * pow(1.0 - ndv, 4.0);
|
||||
vec3 rw = normalize((INV_VIEW_MATRIX * vec4(reflect(-normalize(VIEW), nv), 0.0)).xyz);
|
||||
float up = smoothstep(0.6, 0.95, wnorm.y); // блестят только горизонтальные поверхности
|
||||
vec3 refl = textureLod(sky_tex, pano_uv(rw), 3.0).rgb;
|
||||
ALBEDO = albedo_color * lit * gr * exposure + refl * fres * wet * up * (0.5 + 0.5 * min(lit.r + lit.g + lit.b, 1.0));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwrd86827arnc
|
||||
@@ -0,0 +1,397 @@
|
||||
extends Node3D
|
||||
# Карта «Гонка 2» — «Верхние ярусы» (RACE 2): забег по крышам и мостам мрачного города: старт на верхней платформе, мосты между
|
||||
# башнями, бег по фасадам, площадь с краном, пульсирующие лазерные мосты, спуск по крышам и подъём по спирали вентиляторов
|
||||
# на финальную башню. На каждом участке три пути: безопасный, рискованный и секретный (см. README, «Карта Race 2»).
|
||||
# Модель — race2.glb, данные — race2_data.gd (оба генерирует `Blender models/race_02/build_race.py` из layout.py).
|
||||
# Здесь: внешний вид (материалы по именам из GLB, окружение, дождь, гул), ворота, лазеры, вентиляторы, ускоряющие полосы, срыв,
|
||||
# возрождение на последних воротах. Правила гонки (ожидание, отсчёт, место, итоги, сеть) — общие с картой Race: maps/race/race_rules.gd
|
||||
# (ему передаётся data = race2_data.gd и rating_id); сервер про них ничего не знает.
|
||||
|
||||
const Data = preload("res://maps/race2/race2_data.gd")
|
||||
const Rules = preload("res://maps/race/race_rules.gd")
|
||||
const RATING_ID := "race2" # id карты в рейтинге сервера (Net.TIMED_MAPS)
|
||||
const KILL_DEPTH := 32.0 # Ниже последних пройденных ворот на столько — срыв (то же значение в race_data.gd)
|
||||
const GATE_HEIGHT := 5.0
|
||||
const FLOOR_TO_CENTER := 1.2 # Центр капсулы над полом в данных ворот и слотов
|
||||
|
||||
var player: Node3D
|
||||
var rules: Node
|
||||
var data := Data # для race_rules.gd (общий код правил)
|
||||
var rating_id := RATING_ID
|
||||
var _lasers: Array = [] # {area, mat, mesh, mode, period, on, phase}
|
||||
var _gates: Array = [] # {root, mat, sheet, label} — визуал ворот по индексам CHECKPOINTS
|
||||
|
||||
func _ready() -> void:
|
||||
Net.enter_map("race2")
|
||||
player = get_tree().get_first_node_in_group("local_player")
|
||||
var start: Vector3 = Data.START_SLOTS[0]
|
||||
player.global_position = start
|
||||
player.spawn_transform = Transform3D(Basis.IDENTITY, start)
|
||||
player.death_label.text = "СОРВАЛСЯ!"
|
||||
_prepare_map()
|
||||
_build_environment()
|
||||
for i in Data.CHECKPOINTS.size():
|
||||
_add_gate(i)
|
||||
for l in Data.LASERS:
|
||||
_add_laser(l)
|
||||
for v in Data.VENTS:
|
||||
_add_vent(v)
|
||||
for st in Data.STRIPS:
|
||||
_add_strip(st)
|
||||
_add_rain()
|
||||
_add_ambience()
|
||||
_add_exit()
|
||||
rules = Rules.new()
|
||||
rules.name = "Rules"
|
||||
rules.setup(self)
|
||||
add_child(rules)
|
||||
|
||||
# Цвета поверхностей — те же значения, что в Blender (kit.py: SOLID); освещение уже запечено в цветах вершин
|
||||
const SURFACES := {
|
||||
"Concrete": Color(0.095, 0.105, 0.15), "Dark": Color(0.012, 0.013, 0.024), "Steel": Color(0.115, 0.125, 0.185),
|
||||
"PaintY": Color(0.30, 0.10, 0.36), "PaintR": Color(0.22, 0.05, 0.16), "PaintB": Color(0.04, 0.06, 0.18),
|
||||
"PaintO": Color(0.14, 0.09, 0.30),
|
||||
}
|
||||
# Светящиеся (мрачная палитра): Cyan — холодный белёсый, Magenta — тревожный красный, Orange — натриевая лампа
|
||||
const GLOWS := {
|
||||
"NeonCyan": [Color(0.30, 0.85, 0.98), 0.7], "NeonMagenta": [Color(0.85, 0.08, 0.65), 1.2],
|
||||
"NeonOrange": [Color(0.55, 0.22, 1.0), 1.3], "NeonYellow": [Color(0.72, 0.84, 1.0), 1.0],
|
||||
"NeonGreen": [Color(0.25, 0.95, 0.75), 1.0], "NeonBlue": [Color(0.28, 0.32, 0.92), 1.0],
|
||||
"NeonRed": [Color(1.0, 0.06, 0.28), 1.5], "NeonWhite": [Color(0.72, 0.82, 1.0), 0.9],
|
||||
}
|
||||
|
||||
# Коллизию создаёт импорт (узел Collision-colonly), здесь — внешний вид: материалы по именам из Blender
|
||||
func _prepare_map() -> void:
|
||||
var shader := load("res://maps/race2/baked.gdshader") as Shader
|
||||
var noise := FastNoiseLite.new()
|
||||
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||
noise.frequency = 0.04
|
||||
noise.fractal_octaves = 4
|
||||
var grain := NoiseTexture2D.new()
|
||||
grain.noise = noise
|
||||
grain.width = 512
|
||||
grain.height = 512
|
||||
grain.seamless = true
|
||||
var sky_tex := load("res://maps/race2/sky.png") as Texture2D
|
||||
var cache := {}
|
||||
for mi: MeshInstance3D in $Map.find_children("*", "MeshInstance3D", true, false):
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
for s in mi.mesh.get_surface_count():
|
||||
var src := mi.mesh.surface_get_material(s)
|
||||
var key: String = src.resource_name if src != null else ""
|
||||
if not cache.has(key):
|
||||
if SURFACES.has(key):
|
||||
var m := ShaderMaterial.new()
|
||||
m.shader = shader
|
||||
m.set_shader_parameter("albedo_color", Vector3(SURFACES[key].r, SURFACES[key].g, SURFACES[key].b))
|
||||
m.set_shader_parameter("grain", grain)
|
||||
m.set_shader_parameter("sky_tex", sky_tex)
|
||||
m.set_shader_parameter("exposure", 2.1)
|
||||
cache[key] = m
|
||||
elif GLOWS.has(key):
|
||||
cache[key] = _emissive(GLOWS[key][0], GLOWS[key][1])
|
||||
else:
|
||||
cache[key] = null
|
||||
if cache[key] != null:
|
||||
mi.set_surface_override_material(s, cache[key])
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if player == null:
|
||||
return
|
||||
if not player.is_dead:
|
||||
if player.global_position.y < player.spawn_transform.origin.y - KILL_DEPTH:
|
||||
player.die()
|
||||
else:
|
||||
for l in _lasers:
|
||||
if _laser_active(l) and l.area.overlaps_body(player):
|
||||
player.die()
|
||||
break
|
||||
_update_lasers()
|
||||
_update_rain()
|
||||
|
||||
# ---------- окружение ----------
|
||||
|
||||
func _build_environment() -> void:
|
||||
var sky_mat := PanoramaSkyMaterial.new()
|
||||
sky_mat.panorama = load("res://maps/race2/sky.png")
|
||||
var sky := Sky.new()
|
||||
sky.sky_material = sky_mat
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_SKY
|
||||
env.sky = sky
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR # свет на самом игроке/призраках; уровень освещён запечённо
|
||||
env.ambient_light_color = Color(0.20, 0.22, 0.40)
|
||||
env.ambient_light_energy = 0.6
|
||||
env.fog_enabled = true
|
||||
env.fog_light_color = Color(0.10, 0.09, 0.24) # холодный индиго-дым высоких ярусов
|
||||
env.fog_density = 0.011
|
||||
env.fog_sky_affect = 0.45
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
env.glow_enabled = true
|
||||
env.glow_intensity = 0.55
|
||||
env.glow_bloom = 0.1
|
||||
env.glow_hdr_threshold = 1.0
|
||||
env.adjustment_enabled = true
|
||||
env.adjustment_contrast = 1.15
|
||||
env.adjustment_saturation = 0.9
|
||||
var we := WorldEnvironment.new()
|
||||
we.environment = env
|
||||
add_child(we)
|
||||
var sun := DirectionalLight3D.new() # подсветка только для персонажей
|
||||
sun.rotation_degrees = Vector3(-55.0, 35.0, 0.0)
|
||||
sun.light_color = Color(0.5, 0.58, 0.95)
|
||||
sun.light_energy = 0.4
|
||||
add_child(sun)
|
||||
|
||||
func _emissive(color: Color, energy: float, alpha := 1.0) -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0, 0, 0, alpha)
|
||||
m.emission_enabled = true
|
||||
m.emission = color
|
||||
m.emission_energy_multiplier = energy
|
||||
if alpha < 1.0:
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
return m
|
||||
|
||||
# ---------- ворота: высокие светящиеся рамы поперёк трассы ----------
|
||||
|
||||
const GATE_OFF := Color(0.30, 0.26, 0.55) # ещё не очередные: тусклый индиго
|
||||
const GATE_NEXT := Color(1.0, 0.18, 0.85) # очередные: яркая маджента
|
||||
const GATE_DONE := Color(0.25, 0.95, 0.95) # пройдены: ледяной циан
|
||||
|
||||
# Угол рыскания из данных (0 = вдоль -Z, -90 = на +X) — это ровно rotation.y в Godot
|
||||
func gate_yaw(i: int) -> float:
|
||||
return deg_to_rad(Data.CHECKPOINTS[i][2])
|
||||
|
||||
func gate_pos(i: int) -> Vector3:
|
||||
return Data.CHECKPOINTS[i][0]
|
||||
|
||||
func gate_half(i: int) -> float:
|
||||
return Data.CHECKPOINTS[i][1]
|
||||
|
||||
func _add_gate(i: int) -> void:
|
||||
var last: bool = i == Data.CHECKPOINTS.size() - 1
|
||||
var half: float = gate_half(i)
|
||||
var mat := _emissive(GATE_OFF, 0.9)
|
||||
var sheet := _emissive(GATE_OFF, 0.3, 0.07)
|
||||
var root := Node3D.new()
|
||||
root.position = gate_pos(i) - Vector3(0, FLOOR_TO_CENTER, 0) # в данных — центр капсулы, ворота ставим на пол
|
||||
root.rotation.y = gate_yaw(i) # локальный -Z ворот смотрит по трассе
|
||||
add_child(root)
|
||||
var post_h := GATE_HEIGHT
|
||||
for s in [-1.0, 1.0]:
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = Vector3(0.4, post_h, 0.4)
|
||||
root.add_child(_part(bm, mat, Vector3(s * half, post_h * 0.5, 0)))
|
||||
var top := BoxMesh.new()
|
||||
top.size = Vector3(half * 2 + 0.4, 0.4, 0.4)
|
||||
root.add_child(_part(top, mat, Vector3(0, post_h, 0)))
|
||||
var beams: Array = [] # световые столбы над стойками: ворота видно издалека
|
||||
for s in [-1.0, 1.0]:
|
||||
var bm2 := BoxMesh.new()
|
||||
bm2.size = Vector3(0.7, 70.0, 0.7)
|
||||
var bmat := _emissive(GATE_OFF, 1.0, 0.10)
|
||||
root.add_child(_part(bm2, bmat, Vector3(s * half, post_h + 35.0, 0)))
|
||||
beams.append(bmat)
|
||||
var qm := QuadMesh.new() # слабая светящаяся «плёнка» в проёме
|
||||
qm.size = Vector2(half * 2, post_h)
|
||||
root.add_child(_part(qm, sheet, Vector3(0, post_h * 0.5, 0)))
|
||||
var label := Label3D.new()
|
||||
label.text = "FINISH" if last else "CHECKPOINT %d/%d" % [i + 1, Data.CHECKPOINTS.size()]
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
label.pixel_size = 0.022
|
||||
label.modulate = GATE_OFF
|
||||
label.outline_modulate = Color.BLACK
|
||||
label.position = Vector3(0, post_h + 1.2, 0)
|
||||
root.add_child(label)
|
||||
_gates.append({"root": root, "mat": mat, "sheet": sheet, "label": label, "beams": beams})
|
||||
|
||||
# Раскраска ворот по прогрессу игрока: пройденные зелёные, очередные яркие, остальные тусклые
|
||||
func set_gate_progress(passed: int) -> void:
|
||||
for i in _gates.size():
|
||||
var g: Dictionary = _gates[i]
|
||||
var c := GATE_DONE if i < passed else (GATE_NEXT if i == passed else GATE_OFF)
|
||||
g.mat.emission = c
|
||||
g.sheet.emission = c
|
||||
g.label.modulate = c
|
||||
for b in g.beams:
|
||||
b.emission = c
|
||||
g.mat.emission_energy_multiplier = 1.6 if i == passed else 0.9
|
||||
|
||||
func _part(mesh: Mesh, mat: Material, pos: Vector3) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
mi.material_override = mat
|
||||
mi.position = pos
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
return mi
|
||||
|
||||
# ---------- лазеры ----------
|
||||
|
||||
func _add_laser(d: Array) -> void:
|
||||
var area := Area3D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 1
|
||||
area.position = d[0]
|
||||
var cs := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = d[1]
|
||||
cs.shape = shape
|
||||
area.add_child(cs)
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = d[1]
|
||||
var mat := _emissive(Color(1.0, 0.05, 0.22), 2.4, 0.35 if d[1].y > 3.0 else 1.0)
|
||||
var mesh := _part(bm, mat, Vector3.ZERO)
|
||||
area.add_child(mesh)
|
||||
add_child(area)
|
||||
_lasers.append({"area": area, "mat": mat, "mesh": mesh, "mode": d[2], "period": d[3], "on": d[4], "phase": d[5]})
|
||||
|
||||
# Пульсирующие лазеры считают фазу по системным часам — у всех игроков примерно одинаково
|
||||
func _laser_active(l: Dictionary) -> bool:
|
||||
if l.mode == 0:
|
||||
return true
|
||||
return fposmod(Time.get_unix_time_from_system() + l.phase, l.period) < l.on
|
||||
|
||||
func _update_lasers() -> void:
|
||||
for l in _lasers:
|
||||
var active := _laser_active(l)
|
||||
l.mat.emission_energy_multiplier = 2.4 if active else 0.3
|
||||
l.mesh.visible = active or l.mode == 1
|
||||
|
||||
# ---------- вентиляторы и ускоряющие полосы (objects/vent.gd, objects/boost_strip.gd) ----------
|
||||
|
||||
func _add_vent(d: Array) -> void:
|
||||
var a := Area3D.new()
|
||||
a.set_script(load("res://objects/vent.gd"))
|
||||
a.size = d[1]
|
||||
a.height = d[2]
|
||||
a.hidden = d[3]
|
||||
a.position = d[0]
|
||||
add_child(a)
|
||||
|
||||
func _add_strip(d: Array) -> void:
|
||||
var a := Area3D.new()
|
||||
a.set_script(load("res://objects/boost_strip.gd"))
|
||||
a.size = d[1]
|
||||
a.speed = d[3]
|
||||
a.position = d[0]
|
||||
a.rotation.y = deg_to_rad(d[2])
|
||||
add_child(a)
|
||||
|
||||
# ---------- выход ----------
|
||||
|
||||
func _add_exit() -> void:
|
||||
var portal := Area3D.new()
|
||||
portal.set_script(load("res://objects/portal.gd"))
|
||||
portal.target_map = "hub"
|
||||
portal.target_scene = "res://maps/hub/hub.tscn"
|
||||
portal.title = "HUB"
|
||||
portal.color = Color(0.3, 1, 0.45)
|
||||
portal.position = Data.PORTAL
|
||||
add_child(portal)
|
||||
|
||||
# ---------- дождь и звук ----------
|
||||
|
||||
var _rain: GPUParticles3D
|
||||
|
||||
# Ливень вокруг игрока: тонкие вертикальные штрихи, летящие по ветру; в закрытых местах (COVERED из race2_data.gd) не идёт
|
||||
func _add_rain() -> void:
|
||||
_rain = GPUParticles3D.new()
|
||||
_rain.amount = 2200
|
||||
_rain.lifetime = 0.8
|
||||
_rain.local_coords = false
|
||||
_rain.visibility_aabb = AABB(Vector3(-30, -30, -30), Vector3(60, 60, 60))
|
||||
var pm := ParticleProcessMaterial.new()
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
pm.emission_box_extents = Vector3(26.0, 0.5, 26.0)
|
||||
pm.direction = Vector3(0.12, -1.0, 0.05)
|
||||
pm.spread = 2.0
|
||||
pm.initial_velocity_min = 34.0
|
||||
pm.initial_velocity_max = 40.0
|
||||
pm.gravity = Vector3.ZERO
|
||||
_rain.process_material = pm
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = Vector2(0.018, 0.7)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.albedo_color = Color(0.62, 0.7, 0.95, 0.3)
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_FIXED_Y
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
quad.material = mat
|
||||
_rain.draw_pass_1 = quad
|
||||
_rain.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(_rain)
|
||||
|
||||
func _update_rain() -> void:
|
||||
if _rain == null or player == null:
|
||||
return
|
||||
_rain.global_position = player.global_position + Vector3(0, 12, 0)
|
||||
var p := player.global_position
|
||||
var covered := false
|
||||
for c in Data.COVERED: # траншея и шахта: x0, x1, z0, z1, пол, потолок (z0 > z1: по Z расстояние со знаком минус)
|
||||
if p.x > c[0] and p.x < c[1] and p.z < c[2] and p.z > c[3] and p.y > c[4] and p.y < c[5]:
|
||||
covered = true
|
||||
_rain.emitting = not covered
|
||||
|
||||
# Низкий промышленный гул и шум дождя: 3 секунды без щелчка на стыке петли (края сведены перекрёстным затуханием)
|
||||
func _add_ambience() -> void:
|
||||
const RATE := 22050
|
||||
const FADE := 4410
|
||||
var n := RATE * 3
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 13
|
||||
var raw := PackedFloat32Array()
|
||||
raw.resize(n + FADE)
|
||||
var lp := 0.0
|
||||
for i in n + FADE:
|
||||
lp = lerpf(lp, rng.randf_range(-1.0, 1.0), 0.55) # мягкий «шипящий» шум
|
||||
raw[i] = lp
|
||||
var data := PackedByteArray()
|
||||
data.resize(n * 2)
|
||||
for i in n:
|
||||
var v := raw[i]
|
||||
if i < FADE:
|
||||
var t := float(i) / FADE
|
||||
v = raw[i] * sin(t * PI * 0.5) + raw[n + i] * cos(t * PI * 0.5)
|
||||
var tt := float(i) / RATE
|
||||
var hum := 0.16 * sin(TAU * 42.0 * tt) + 0.08 * sin(TAU * 63.0 * tt) + 0.05 * sin(TAU * 21.0 * tt)
|
||||
data.encode_s16(i * 2, int(clampf(v * 0.45 + hum, -1.0, 1.0) * 14000.0))
|
||||
var wav := AudioStreamWAV.new()
|
||||
wav.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
wav.mix_rate = RATE
|
||||
wav.stereo = false
|
||||
wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
wav.loop_begin = 0
|
||||
wav.loop_end = n
|
||||
wav.data = data
|
||||
var ap := AudioStreamPlayer.new()
|
||||
ap.stream = wav
|
||||
ap.volume_db = -13.0
|
||||
ap.autoplay = true
|
||||
add_child(ap)
|
||||
|
||||
# Песня гонки (пока та же, что у Race; своя музыка — фаза 2): Ogg Vorbis, моно 32 кГц (~0,6 МБ). Включается в момент «СТАРТ!» (race_rules.gd), плавно затихает при возврате в ожидание
|
||||
const MUSIC_DB := -9.0
|
||||
var _music: AudioStreamPlayer
|
||||
var _music_tween: Tween
|
||||
|
||||
func set_music(on: bool) -> void:
|
||||
if _music == null:
|
||||
var stream := load("res://maps/race2/audio/music.ogg") as AudioStreamOggVorbis
|
||||
stream.loop = true
|
||||
_music = AudioStreamPlayer.new()
|
||||
_music.stream = stream
|
||||
_music.volume_db = MUSIC_DB
|
||||
add_child(_music)
|
||||
if _music_tween:
|
||||
_music_tween.kill()
|
||||
if on:
|
||||
if not _music.playing:
|
||||
_music.volume_db = MUSIC_DB
|
||||
_music.play() # каждый круг — с начала
|
||||
elif _music.playing:
|
||||
_music_tween = create_tween()
|
||||
_music_tween.tween_property(_music, "volume_db", -60.0, 0.8)
|
||||
_music_tween.tween_callback(_music.stop)
|
||||
@@ -0,0 +1 @@
|
||||
uid://djwekkr3vawop
|
||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bepdwblao0vt"
|
||||
path="res://.godot/imported/race2.glb-855c5ef0b2a873d4c22fd233e8a67730.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://maps/race2/race2.glb"
|
||||
dest_files=["res://.godot/imported/race2.glb-855c5ef0b2a873d4c22fd233e8a67730.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
@@ -0,0 +1,12 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://maps/race2/race2.gd" id="1_level"]
|
||||
[ext_resource type="PackedScene" path="res://maps/race2/race2.glb" id="2_map"]
|
||||
[ext_resource type="PackedScene" path="res://player/player.tscn" id="3_player"]
|
||||
|
||||
[node name="Race2" type="Node3D"]
|
||||
script = ExtResource("1_level")
|
||||
|
||||
[node name="Map" parent="." instance=ExtResource("2_map")]
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("3_player")]
|
||||
@@ -0,0 +1,88 @@
|
||||
# Сгенерировано Blender models/race_02/build_race.py из layout.py — не править руками
|
||||
extends RefCounted
|
||||
|
||||
# Старт: 8 слотов в ряд поперёк трассы, лицом по трассе (-Z); центр капсулы
|
||||
const START_SLOTS := [
|
||||
Vector3(-9.80, 61.25, -28.00),
|
||||
Vector3(-7.00, 61.25, -28.00),
|
||||
Vector3(-4.20, 61.25, -28.00),
|
||||
Vector3(-1.40, 61.25, -28.00),
|
||||
Vector3(1.40, 61.25, -28.00),
|
||||
Vector3(4.20, 61.25, -28.00),
|
||||
Vector3(7.00, 61.25, -28.00),
|
||||
Vector3(9.80, 61.25, -28.00),
|
||||
]
|
||||
|
||||
# Ворота по порядку: [центр капсулы на полу ворот, полуширина поперёк трассы (м), рыскание (град; 0 = -Z, -90 = на +X, 180 = на +Z)]. Последний = ФИНИШ
|
||||
# Ворота стоят там, где ветки (безопасная / рискованная / секретная) сливаются.
|
||||
const CHECKPOINTS := [
|
||||
[Vector3(0.00, 61.20, -70.00), 8.0, 0.0], # cp1
|
||||
[Vector3(0.00, 62.20, -104.00), 17.0, 0.0], # cp2
|
||||
[Vector3(107.00, 62.20, -232.00), 25.0, 0.0], # cp3
|
||||
[Vector3(109.00, 53.00, -352.00), 12.0, 0.0], # cp4
|
||||
[Vector3(110.00, 53.00, -384.00), 20.0, 0.0], # cp5
|
||||
[Vector3(110.00, 53.00, -468.00), 50.0, 0.0], # cp6
|
||||
[Vector3(110.00, 53.00, -508.00), 22.0, 0.0], # cp7
|
||||
[Vector3(110.00, 53.00, -577.00), 26.0, 0.0], # cp8
|
||||
[Vector3(110.00, 53.00, -634.00), 18.0, 0.0], # cp9
|
||||
[Vector3(150.00, 48.00, -652.00), 28.0, -90.0], # cp10
|
||||
[Vector3(200.00, 29.00, -560.00), 12.0, 180.0], # cp11
|
||||
[Vector3(203.00, 25.00, -530.00), 33.0, 180.0], # cp12
|
||||
[Vector3(210.00, 75.00, -488.00), 14.0, 180.0], # cp13
|
||||
]
|
||||
|
||||
# Лазеры: центр, размер, режим (0 — всегда, 1 — пульсирует), период, время включения, сдвиг
|
||||
const LASERS := [
|
||||
[Vector3(108.00, 52.25, -544.25), Vector3(4.00, 0.90, 0.50), 1, 3.00, 1.00, 0.00], # mL0
|
||||
[Vector3(112.00, 52.25, -544.25), Vector3(4.00, 0.90, 0.50), 1, 3.00, 1.00, 1.50], # mR0
|
||||
[Vector3(108.00, 53.45, -560.25), Vector3(4.00, 0.70, 0.50), 1, 3.00, 1.00, 0.00], # mL1
|
||||
[Vector3(112.00, 53.45, -560.25), Vector3(4.00, 0.70, 0.50), 1, 3.00, 1.00, 1.50], # mR1
|
||||
[Vector3(108.00, 52.25, -576.25), Vector3(4.00, 0.90, 0.50), 1, 3.00, 1.00, 0.00], # mL2
|
||||
[Vector3(112.00, 52.25, -576.25), Vector3(4.00, 0.90, 0.50), 1, 3.00, 1.00, 1.50], # mR2
|
||||
[Vector3(108.00, 53.45, -592.25), Vector3(4.00, 0.70, 0.50), 1, 3.00, 1.00, 0.00], # mL3
|
||||
[Vector3(112.00, 53.45, -592.25), Vector3(4.00, 0.70, 0.50), 1, 3.00, 1.00, 1.50], # mR3
|
||||
[Vector3(108.00, 52.25, -608.25), Vector3(4.00, 0.90, 0.50), 1, 3.00, 1.00, 0.00], # mL4
|
||||
[Vector3(112.00, 52.25, -608.25), Vector3(4.00, 0.90, 0.50), 1, 3.00, 1.00, 1.50], # mR4
|
||||
]
|
||||
|
||||
# Вентиляционные потоки: центр решётки на полу, сечение (X, Z), высота столба, скрытый (без света и столба — шахта-лифт)
|
||||
const VENTS := [
|
||||
[Vector3(107.00, 43.80, -450.00), Vector2(6.00, 6.00), 14.00, false], # v_tn
|
||||
[Vector3(230.00, 23.80, -513.00), Vector2(6.00, 6.00), 15.00, false], # v1
|
||||
[Vector3(230.00, 36.30, -477.00), Vector2(6.00, 6.00), 15.00, false], # v2
|
||||
[Vector3(198.00, 48.80, -465.00), Vector2(6.00, 6.00), 15.00, false], # v3
|
||||
[Vector3(190.00, 61.30, -481.00), Vector2(6.00, 6.00), 15.00, false], # v4
|
||||
[Vector3(210.00, 23.80, -501.75), Vector2(3.00, 3.50), 53.00, true], # v_lift
|
||||
]
|
||||
|
||||
# Ускоряющие полосы: центр на полу, размер (ширина, длина), рыскание (направление разгона), скорость
|
||||
const STRIPS := [
|
||||
[Vector3(61.00, 62.00, -105.00), Vector2(6.00, 8.00), -45.0, 22.0], # s_p2a
|
||||
[Vector3(0.00, 60.00, -70.00), Vector2(6.00, 14.00), 0.0, 22.0], # s_b1
|
||||
[Vector3(9.00, 61.00, -105.00), Vector2(6.00, 16.00), -90.0, 22.0], # s_T2
|
||||
[Vector3(79.00, 52.80, -304.00), Vector2(5.00, 8.00), 0.0, 22.0], # s_ruin
|
||||
[Vector3(109.00, 51.80, -335.00), Vector2(6.00, 8.00), 0.0, 22.0], # s_LM
|
||||
[Vector3(107.00, 43.80, -408.00), Vector2(5.00, 10.00), 0.0, 22.0], # s_tn
|
||||
[Vector3(101.00, 48.20, -524.00), Vector2(4.00, 12.00), 0.0, 22.0], # s_cw524
|
||||
[Vector3(101.00, 48.20, -548.00), Vector2(4.00, 12.00), 0.0, 22.0], # s_cw548
|
||||
[Vector3(101.00, 48.20, -572.00), Vector2(4.00, 12.00), 0.0, 22.0], # s_cw572
|
||||
[Vector3(101.00, 48.20, -596.00), Vector2(4.00, 12.00), 0.0, 22.0], # s_cw596
|
||||
[Vector3(149.00, 46.80, -649.00), Vector2(6.00, 14.00), -90.0, 22.0], # s_d1
|
||||
[Vector3(113.00, 51.80, -642.00), Vector2(6.00, 14.00), -90.0, 22.0], # s_q1
|
||||
[Vector3(202.00, 41.80, -652.00), Vector2(6.00, 14.00), 180.0, 22.0], # s_d3
|
||||
[Vector3(202.00, 34.80, -604.00), Vector2(6.00, 10.00), 180.0, 22.0], # s_d5
|
||||
[Vector3(200.00, 23.80, -530.00), Vector2(6.00, 10.00), 180.0, 22.0], # s_f0
|
||||
]
|
||||
|
||||
# Закрытые места без дождя (траншея, шахта): x0, x1, z0, z1 (Godot), пол, потолок
|
||||
const COVERED := [
|
||||
[102.0, 112.0, -408.0, -452.0, 42.8, 47.2], # траншея
|
||||
[206.5, 213.5, -505.5, -500.0, 22.8, 76.8], # шахта-лифт
|
||||
]
|
||||
|
||||
# Портал в хаб за финишем: позиция на полу площадки
|
||||
const PORTAL := Vector3(210.00, 73.80, -476.00)
|
||||
# Длина безопасного маршрута, м
|
||||
const TRACK_LENGTH := 1228.9
|
||||
# Срыв: на столько ниже последнего чекпоинта — возрождение
|
||||
const KILL_DEPTH := 32.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://c01qphr2te2ln
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://fqq72yjnny11"
|
||||
path.s3tc="res://.godot/imported/sky.png-b8c1bf32feecd75c07d68b59edc4fccf.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://maps/race2/sky.png"
|
||||
dest_files=["res://.godot/imported/sky.png-b8c1bf32feecd75c07d68b59edc4fccf.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
+8
-2
@@ -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()
|
||||
|
||||
+356
@@ -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}, "race2": {"min": 44.0}, "parkour": {"min": 40.0}}
|
||||
const MAP_TITLES := {"race": "Гонка", "race2": "Гонка 2", "parkour": "Паркур", "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,321 @@ 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
|
||||
_save_leaderboard() # новый рекорд пишем сразу: рекорды редки, а перезапуск службы (SIGTERM) иначе терял бы последние ≤ SAVE_EVERY секунд
|
||||
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 +561,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 +583,7 @@ func _reset_room_state() -> void:
|
||||
rock_owner = 0
|
||||
chaser_states.clear()
|
||||
hooked_player = null
|
||||
nicks.clear()
|
||||
map_ready = false
|
||||
|
||||
# Мы перешли на другую карту (вызывает сама карта при загрузке, идемпотентно)
|
||||
@@ -308,6 +662,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 +679,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
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
extends Area3D
|
||||
# Ускоряющая полоса: на ней игрок, бегущий вдоль неё, быстро добирает скорость (до speed, но не выше MAX_SPEED игрока).
|
||||
# Направление — локальная ось -Z узла (rotation.y = рыскание, как у ворот гонки). Эффект локальный, в сеть не уходит.
|
||||
# Начало координат — центр полосы на полу. Рисуется кодом: бирюзовые стрелки, бегущие по ходу.
|
||||
|
||||
@export var size := Vector2(6.0, 10.0) # ширина (X) и длина (Z), м
|
||||
@export var speed := 22.0
|
||||
@export var color := Color(0.85, 0.45, 1.0) # пурпурно-белый: отличается от ледяных стрелок-подсказок трассы
|
||||
|
||||
const SHADER := """
|
||||
shader_type spatial;
|
||||
render_mode unshaded, blend_add, cull_disabled, depth_draw_never;
|
||||
uniform vec4 tint : source_color = vec4(0.85, 0.45, 1.0, 1.0);
|
||||
uniform vec2 dims = vec2(6.0, 10.0);
|
||||
varying vec2 uv;
|
||||
void vertex() { uv = VERTEX.xz; }
|
||||
void fragment() {
|
||||
// uv.y < 0 — вперёд (-Z); шевроны бегут вперёд
|
||||
float across = abs(uv.x) / (dims.x * 0.5);
|
||||
float t = -uv.y * 0.35 - TIME * 2.6 + across * 0.9;
|
||||
float chev = smoothstep(0.55, 0.62, fract(t)) * (1.0 - smoothstep(0.78, 0.9, fract(t)));
|
||||
float edge = smoothstep(0.86, 0.94, across);
|
||||
float end = smoothstep(0.0, 0.1, 0.5 - abs(uv.y) / dims.y);
|
||||
ALBEDO = tint.rgb;
|
||||
ALPHA = clamp((chev * 0.85 + edge * 0.7 + 0.1) * end, 0.0, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = 0
|
||||
collision_mask = 1
|
||||
var cs := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = Vector3(size.x, 2.4, size.y)
|
||||
cs.shape = shape
|
||||
cs.position.y = 1.2
|
||||
add_child(cs)
|
||||
var sh := Shader.new()
|
||||
sh.code = SHADER
|
||||
var sm := ShaderMaterial.new()
|
||||
sm.shader = sh
|
||||
sm.set_shader_parameter("tint", color)
|
||||
sm.set_shader_parameter("dims", size)
|
||||
var pm := PlaneMesh.new()
|
||||
pm.size = size
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = pm
|
||||
mi.material_override = sm
|
||||
mi.position.y = 0.07
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(mi)
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
var dir := -global_transform.basis.z
|
||||
for b in get_overlapping_bodies():
|
||||
if b.has_method("apply_boost"):
|
||||
b.apply_boost(dir, speed)
|
||||
@@ -0,0 +1 @@
|
||||
uid://7fr8utm135ud
|
||||
@@ -0,0 +1,93 @@
|
||||
extends Area3D
|
||||
# Вентиляционный поток: столб воздуха вверх из решётки. Пока игрок внутри, player.apply_vent() тянет его вверх
|
||||
# (вертикальная скорость растёт до VENT_MAX_RISE, горизонтальная сохраняется, двойной прыжок возвращается).
|
||||
# Эффект локальный (только на игроке), в сеть не уходит. Рисуется кодом: решётка на полу, мерцающий столб, тонкий свет.
|
||||
# Начало координат — центр решётки на полу; столб идёт вверх на height.
|
||||
|
||||
@export var size := Vector2(6.0, 6.0) # сечение столба (X, Z), м
|
||||
@export var height := 14.0 # высота столба, м
|
||||
@export var color := Color(0.25, 0.95, 0.75)
|
||||
@export var hidden := false # скрытый поток (шахта-лифт): только тёмная решётка, без столба, щелей и света
|
||||
|
||||
const SHADER := """
|
||||
shader_type spatial;
|
||||
render_mode unshaded, blend_add, cull_disabled, depth_draw_never;
|
||||
uniform vec4 tint : source_color = vec4(0.45, 0.85, 0.35, 1.0);
|
||||
uniform float height = 14.0;
|
||||
varying vec3 lp;
|
||||
void vertex() { lp = VERTEX; }
|
||||
void fragment() {
|
||||
float y = lp.y / height;
|
||||
float bands = 0.55 + 0.45 * sin((lp.y * 0.9 - TIME * 7.0) + lp.x * 1.7 + lp.z * 2.3);
|
||||
float fade = smoothstep(0.0, 0.08, y) * (1.0 - smoothstep(0.55, 1.0, y));
|
||||
ALBEDO = tint.rgb;
|
||||
ALPHA = clamp(bands * fade * 0.22, 0.0, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = 0
|
||||
collision_mask = 1
|
||||
var cs := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = Vector3(size.x, height, size.y)
|
||||
cs.shape = shape
|
||||
cs.position.y = height * 0.5
|
||||
add_child(cs)
|
||||
_build_visuals()
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
for b in get_overlapping_bodies():
|
||||
if b.has_method("apply_vent"):
|
||||
b.apply_vent()
|
||||
|
||||
func _build_visuals() -> void:
|
||||
# мерцающий столб
|
||||
var sh := Shader.new()
|
||||
sh.code = SHADER
|
||||
var sm := ShaderMaterial.new()
|
||||
sm.shader = sh
|
||||
sm.set_shader_parameter("tint", color)
|
||||
sm.set_shader_parameter("height", height)
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = Vector3(size.x, height, size.y)
|
||||
var col := MeshInstance3D.new()
|
||||
col.mesh = bm
|
||||
col.material_override = sm
|
||||
col.position.y = height * 0.5
|
||||
col.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
if not hidden:
|
||||
add_child(col)
|
||||
# решётка на полу: тёмная плита с светящимися щелями
|
||||
var dark := StandardMaterial3D.new()
|
||||
dark.albedo_color = Color(0.02, 0.025, 0.03)
|
||||
var glow := StandardMaterial3D.new()
|
||||
glow.albedo_color = Color(0, 0, 0)
|
||||
glow.emission_enabled = true
|
||||
glow.emission = color
|
||||
glow.emission_energy_multiplier = 2.2
|
||||
var plate := BoxMesh.new()
|
||||
plate.size = Vector3(size.x - 0.3, 0.08, size.y - 0.3)
|
||||
_add(plate, dark, Vector3(0, 0.04, 0))
|
||||
var n := 0 if hidden else int((size.x - 0.8) / 0.7)
|
||||
for i in n:
|
||||
var slit := BoxMesh.new()
|
||||
slit.size = Vector3(0.22, 0.03, size.y - 1.0)
|
||||
_add(slit, glow, Vector3(-(size.x - 0.8) * 0.5 + (i + 0.5) * (size.x - 0.8) / n, 0.095, 0))
|
||||
if hidden:
|
||||
return
|
||||
# слабый свет над решёткой
|
||||
var l := OmniLight3D.new()
|
||||
l.light_color = color
|
||||
l.light_energy = 0.8
|
||||
l.omni_range = maxf(size.x, size.y) * 1.8
|
||||
l.position.y = 1.5
|
||||
add_child(l)
|
||||
|
||||
func _add(mesh: Mesh, mat: Material, pos: Vector3) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
mi.material_override = mat
|
||||
mi.position = pos
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(mi)
|
||||
@@ -0,0 +1 @@
|
||||
uid://v76iegf4bft
|
||||
+68
-13
@@ -116,6 +116,16 @@ void fragment() {
|
||||
"""
|
||||
|
||||
# Настройки чувствительности мыши
|
||||
# --- Внешние эффекты карт (objects/vent.gd, objects/boost_strip.gd): локально, в сеть не уходят ---
|
||||
const VENT_ACCEL = 80.0 # Подъёмное ускорение в вентиляционном потоке
|
||||
const VENT_MAX_RISE = 16.0 # Поток разгоняет вертикальную скорость не выше этой
|
||||
const BOOST_ACCEL = 60.0 # Разгон на ускоряющей полосе (потолок — MAX_SPEED)
|
||||
const EFFECT_HOLD = 0.12 # Сколько секунд эффект держится после последнего вызова (область зовёт каждый кадр)
|
||||
var vent_timer: float = 0.0
|
||||
var boost_timer: float = 0.0
|
||||
var boost_dir: Vector3 = Vector3.ZERO
|
||||
var boost_speed: float = MAX_SPEED
|
||||
|
||||
@export var MOUSE_SENSITIVITY: float = 0.003
|
||||
|
||||
# --- Настройки тряски камеры (Head Bobbing) ---
|
||||
@@ -197,7 +207,8 @@ func _ready() -> void:
|
||||
add_to_group("local_player")
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
head_base_pos = head.position
|
||||
base_fov = head.fov
|
||||
base_fov = Settings.fov
|
||||
head.fov = base_fov
|
||||
|
||||
# Тело видно в редакторе и отбрасывает тень, но своя камера его не рисует (слой 2)
|
||||
for mi: MeshInstance3D in $Model.find_children("*", "MeshInstance3D", true, false):
|
||||
@@ -235,22 +246,18 @@ func _setup_vignette() -> void:
|
||||
rect.material = vignette_material
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
# Esc / Start теперь открывают меню паузы (core/pause_menu.gd), оно само освобождает мышь
|
||||
|
||||
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
|
||||
|
||||
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
var zoom_factor := head.fov / base_fov
|
||||
rotate_y(-event.relative.x * MOUSE_SENSITIVITY * zoom_factor)
|
||||
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))
|
||||
_mouse_look(event.relative)
|
||||
|
||||
if input_frozen:
|
||||
if input_frozen or Net.input_blocked:
|
||||
return
|
||||
|
||||
if event.is_action_pressed("crouch"):
|
||||
@@ -258,6 +265,32 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
elif event.is_action_pressed("dash"):
|
||||
_try_dash()
|
||||
|
||||
# Обзор мышью: смещение в пикселях -> поворот с учётом чувствительности из настроек и зума
|
||||
func _mouse_look(relative: Vector2) -> void:
|
||||
var k := MOUSE_SENSITIVITY * Settings.mouse_sens * head.fov / base_fov
|
||||
_look(relative.x * k, relative.y * k)
|
||||
|
||||
# Поворот обзора (радианы за событие/кадр); вертикаль можно инвертировать в настройках
|
||||
func _look(dx: float, dy: float) -> void:
|
||||
if Settings.invert_y:
|
||||
dy = -dy
|
||||
rotate_y(-dx)
|
||||
head.rotate_x(-dy)
|
||||
head.rotation.x = clamp(head.rotation.x, deg_to_rad(-80), deg_to_rad(80))
|
||||
|
||||
# Обзор правым стиком геймпада (плавно, каждый кадр); кривая 1.6 даёт точность у центра и быстрый разворот на краю
|
||||
func _process(delta: float) -> void:
|
||||
base_fov = Settings.fov
|
||||
if is_dead or Net.input_blocked: # открыто меню/панель ника (на отсчёте гонки обзор разрешён, как мышью)
|
||||
return
|
||||
var v := Input.get_vector("look_left", "look_right", "look_up", "look_down")
|
||||
if v == Vector2.ZERO:
|
||||
return
|
||||
var strength := pow(minf(v.length(), 1.0), 1.6)
|
||||
v = v.normalized() * strength
|
||||
var k := Settings.STICK_LOOK_SPEED * Settings.stick_sens * delta * head.fov / base_fov
|
||||
_look(v.x * k, v.y * k)
|
||||
|
||||
# Битовые флаги состояния для сетевой синхронизации (см. Net.FLAG_*)
|
||||
func get_net_flags() -> int:
|
||||
var flags := 0
|
||||
@@ -279,7 +312,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
|
||||
@@ -418,17 +451,39 @@ func _physics_process(delta: float) -> void:
|
||||
if horizontal.length() > speed_cap:
|
||||
horizontal = horizontal.normalized() * speed_cap
|
||||
|
||||
# Полоса: разгоняет вдоль своего направления, но не выше потолка MAX_SPEED
|
||||
if boost_timer > 0.0:
|
||||
boost_timer -= delta
|
||||
var hs := horizontal.length()
|
||||
if hs > 3.0 and horizontal.normalized().dot(boost_dir) > 0.5:
|
||||
horizontal = horizontal.normalized() * maxf(hs, minf(hs + BOOST_ACCEL * delta, boost_speed)) # только добирает: быстрее полосы не тормозит
|
||||
if horizontal.length() > MAX_SPEED:
|
||||
horizontal = horizontal.normalized() * MAX_SPEED
|
||||
|
||||
velocity.x = horizontal.x
|
||||
velocity.z = horizontal.z
|
||||
|
||||
# Вентиляционный поток: тянет вверх (горизонтальная скорость не трогается), возвращает двойной прыжок
|
||||
if vent_timer > 0.0:
|
||||
vent_timer -= delta
|
||||
velocity.y = minf(velocity.y + VENT_ACCEL * delta, maxf(velocity.y, VENT_MAX_RISE))
|
||||
air_jumps_left = AIR_JUMPS
|
||||
|
||||
# 6. Камера
|
||||
_update_camera(delta, input_dir)
|
||||
|
||||
_move_and_track(delta)
|
||||
|
||||
# Вызывает область-вентилятор каждый физический кадр, пока игрок в потоке
|
||||
func apply_vent() -> void:
|
||||
vent_timer = EFFECT_HOLD
|
||||
|
||||
# Вызывает ускоряющая полоса каждый кадр: dir — куда она разгоняет (горизонтально), speed — до какой скорости
|
||||
func apply_boost(dir: Vector3, speed: float) -> void:
|
||||
boost_timer = EFFECT_HOLD
|
||||
boost_dir = Vector3(dir.x, 0.0, dir.z).normalized()
|
||||
boost_speed = minf(speed, MAX_SPEED)
|
||||
|
||||
# Двигаемся и отслеживаем приземление
|
||||
func _move_and_track(delta: float) -> void:
|
||||
var pre_vy := velocity.y
|
||||
@@ -855,8 +910,8 @@ func _update_camera(delta: float, input_dir: Vector2) -> void:
|
||||
head.rotation.z = current_roll + randf_range(-1.0, 1.0) * shake_strength * 0.3
|
||||
|
||||
# Зум на ЛКМ
|
||||
is_zooming = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) \
|
||||
and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
|
||||
is_zooming = (Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED) \
|
||||
or (Input.is_action_pressed("zoom") and not Net.input_blocked)
|
||||
|
||||
# FOV: зум > рывок > слайд > стена > скорость бега
|
||||
var target_fov := base_fov + sprint_factor * SPRINT_FOV_BONUS
|
||||
|
||||
+24
-2
@@ -10,15 +10,22 @@ config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="world2"
|
||||
config/name="Aman's World"
|
||||
run/main_scene="res://maps/hub/hub.tscn"
|
||||
config/use_custom_user_dir=true
|
||||
config/custom_user_dir_name="world2"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
boot_splash/bg_color=Color(0.031, 0.031, 0.039, 1)
|
||||
boot_splash/image="res://branding/splash.png"
|
||||
config/icon="res://branding/icon.svg"
|
||||
|
||||
[autoload]
|
||||
|
||||
Net="*res://net/net.gd"
|
||||
MapLoader="*res://core/map_loader.gd"
|
||||
Settings="*res://core/settings.gd"
|
||||
Overlay="*res://core/overlay.gd"
|
||||
PauseMenu="*res://core/pause_menu.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -76,6 +83,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]
|
||||
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Экспорт веб-клиента и пакетов карт. По умолчанию — в ../grid-world (репозиторий сайта).
|
||||
# tools/export_all.sh [папка_вывода]
|
||||
# Пакеты карт (backrooms.pck, world.pck, parkour.pck, race.pck) нужно выкладывать вместе с index.* — клиент качает их при входе в портал.
|
||||
# Пакеты карт (backrooms.pck, world.pck, parkour.pck, race.pck, race2.pck) нужно выкладывать вместе с index.* — клиент качает их при входе в портал.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT="${1:-../grid-world}"
|
||||
@@ -16,4 +16,5 @@ godot --headless --export-pack Backrooms "$OUT/backrooms.pck"
|
||||
godot --headless --export-pack World "$OUT/world.pck"
|
||||
godot --headless --export-pack Parkour "$OUT/parkour.pck"
|
||||
godot --headless --export-pack Race "$OUT/race.pck"
|
||||
godot --headless --export-pack Race2 "$OUT/race2.pck"
|
||||
ls -la "$OUT"/*.pck "$OUT"/index.wasm | awk '{printf "%7.2f МБ %s\n", $5/1e6, $9}'
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
extends Node
|
||||
# Тестовый бот (автозагрузка во ВРЕМЕННОЙ копии проекта, см. tools/test/make_test_project.sh).
|
||||
# Пережидает смену сцен, по расписанию телепортирует игрока и раз в секунду печатает, что видит.
|
||||
# godot --headless --path <копия> res://maps/hub/hub.tscn -- --client --name=A --tp=2:-2.25,1,-7.5 --tp=9:0,1,5
|
||||
# godot --headless --path <копия> res://maps/hub/hub.tscn -- --client --name=A --tp=2:-12,1,-15 --tp=9:0,1,11
|
||||
# --tp=<секунда>:<x>,<y>,<z> — телепорт игрока на этой секунде (например, прямо в портал).
|
||||
# Остальные аргументы пользователя (--server-url=..., --pack-url=...) читают Net и MapLoader.
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
extends Node
|
||||
# Облёт хаба для кадров: телепорт игрока по точкам, кадры пишет Godot (--write-movie).
|
||||
# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/h.png --quit-after 45 res://tools/test/hub_tour.tscn -- --client --server-url=ws://127.0.0.1:1
|
||||
# Меню паузы: добавьте `--menu=main|settings|controls` (тогда в кадре меню на хабе, остальной облёт отключён).
|
||||
# Кадры: h00000005.png и т. д. (каждые 5 кадров — следующая точка из spots).
|
||||
var level
|
||||
var p
|
||||
var frame := 0
|
||||
# позиция центра капсулы, рыскание (0 = на север, к порталам), наклон головы (градусы)
|
||||
var spots := [
|
||||
[Vector3(0, 1.25, 12), 0, 4], [Vector3(-10, 1.25, -6), 90, 8], [Vector3(10, 1.25, 8), -90, 6],
|
||||
[Vector3(0, 1.25, -9), 0, 12], [Vector3(0, 1.25, -5), 180, 10], [Vector3(14, 1.25, -9), 0, 8],
|
||||
[Vector3(-20, 1.25, 2), -90, 0], [Vector3(0, 6.0, 13), 0, -18],
|
||||
]
|
||||
func _ready() -> void:
|
||||
Net.nick_asked = true # без панели «Введите ник»
|
||||
level = (load("res://maps/hub/hub.tscn") as PackedScene).instantiate()
|
||||
add_child(level)
|
||||
p = level.get_node("Player")
|
||||
p.set_physics_process(false)
|
||||
func _process(_d: float) -> void:
|
||||
frame += 1
|
||||
for a in OS.get_cmdline_user_args():
|
||||
if a.begins_with("--menu="):
|
||||
if frame == 3:
|
||||
PauseMenu.open_menu()
|
||||
PauseMenu._show_page(a.substr(7))
|
||||
return
|
||||
var i := int(frame / 5)
|
||||
if i < spots.size() and frame % 5 == 1:
|
||||
var s = spots[i]
|
||||
p.global_position = s[0]
|
||||
p.rotation.y = deg_to_rad(s[1])
|
||||
p.head.rotation.x = deg_to_rad(s[2])
|
||||
@@ -0,0 +1 @@
|
||||
uid://dndxmq38i6wyg
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/test/hub_tour.gd" id="1"]
|
||||
|
||||
[node name="T" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -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:<id>, scene:hub, sleep:<с>, waitghosts:<n>, 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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn2e2m283kvpr
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://pc0m8rg1dhnf
|
||||
@@ -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")
|
||||
Executable
+217
@@ -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, "<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": {}, "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)
|
||||
@@ -0,0 +1,33 @@
|
||||
extends Node
|
||||
# Геймпад в меню паузы: фокус, A выбирает, крестовина двигает.
|
||||
func frames(n: int) -> void:
|
||||
for i in n:
|
||||
await get_tree().process_frame
|
||||
func btn(b: JoyButton, pressed: bool) -> void:
|
||||
var e := InputEventJoypadButton.new()
|
||||
e.button_index = b
|
||||
e.pressed = pressed
|
||||
Input.parse_input_event(e)
|
||||
func _ready() -> void:
|
||||
Net.nick_asked = true
|
||||
add_child((load("res://maps/hub/hub.tscn") as PackedScene).instantiate())
|
||||
await frames(5)
|
||||
PauseMenu.open_menu()
|
||||
await frames(5)
|
||||
var f := get_viewport().gui_get_focus_owner()
|
||||
print("фокус: ", f, " текст: ", f.text if f else "—")
|
||||
btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2)
|
||||
f = get_viewport().gui_get_focus_owner()
|
||||
print("после вниз: ", f.text if f else "—")
|
||||
btn(JOY_BUTTON_DPAD_UP, true); await frames(2); btn(JOY_BUTTON_DPAD_UP, false); await frames(2)
|
||||
btn(JOY_BUTTON_A, true); await frames(2); btn(JOY_BUTTON_A, false); await frames(3)
|
||||
print("после A на «Продолжить»: меню открыто = ", PauseMenu.open)
|
||||
PauseMenu.open_menu(); await frames(5)
|
||||
btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2)
|
||||
btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2)
|
||||
btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2)
|
||||
f = get_viewport().gui_get_focus_owner()
|
||||
print("фокус перед A: ", f.text if f else "—")
|
||||
btn(JOY_BUTTON_A, true); await frames(2); btn(JOY_BUTTON_A, false); await frames(3)
|
||||
print("страница после A: ", PauseMenu._page)
|
||||
get_tree().quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgonoxtgka01j
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/test/pad_menu_test.gd" id="1"]
|
||||
|
||||
[node name="T" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -1,7 +1,7 @@
|
||||
# Проверка карты Parkour «по существу» после прохода через портал с настоящими пакетами.
|
||||
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...")
|
||||
# и запусти: godot --headless --main-pack <site>/index.pck -- --client --name=T --pack-url=http://127.0.0.1:<порт>/ --tp=2:4.5,1,-8
|
||||
# Ожидание: скрипт=true, шейдерных_мешей>=1, небо_панорама=true, музыка_играет=true, лазеров_и_ворот=10.
|
||||
# и запусти: godot --headless --main-pack <site>/index.pck -- --client --name=T --pack-url=http://127.0.0.1:<порт>/ --tp=2:0,1,-15
|
||||
# Ожидание (после рейтинга: таймер=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])
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cuykf4tuyn0ci
|
||||
Executable
+19
@@ -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)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,138 @@
|
||||
extends Node
|
||||
# Проверка меню паузы, настроек и геймпада без окна:
|
||||
# godot --headless --path . res://tools/test/pause_test.tscn -- --client --server-url=ws://127.0.0.1:1
|
||||
# Печатает строки OK/FAIL и «ИТОГО». Хаб загружается как сцена-потомок; последний шаг проверяет «Выйти в хаб».
|
||||
var level
|
||||
var p
|
||||
var fails := 0
|
||||
|
||||
func check(name: String, cond: bool) -> void:
|
||||
print(("OK " if cond else "FAIL ") + name)
|
||||
if not cond:
|
||||
fails += 1
|
||||
|
||||
func act(action: String, pressed := true) -> void:
|
||||
var e := InputEventAction.new()
|
||||
e.action = action
|
||||
e.pressed = pressed
|
||||
Input.parse_input_event(e)
|
||||
|
||||
func frames(n: int) -> void:
|
||||
for i in n:
|
||||
await get_tree().process_frame
|
||||
|
||||
func _ready() -> void:
|
||||
Net.nick_asked = true
|
||||
level = (load("res://maps/hub/hub.tscn") as PackedScene).instantiate()
|
||||
add_child(level)
|
||||
p = level.get_node("Player")
|
||||
await frames(5)
|
||||
await _run()
|
||||
print("ИТОГО: %s" % ("всё хорошо" if fails == 0 else "провалов %d" % fails))
|
||||
get_tree().quit(fails)
|
||||
|
||||
func _run() -> void:
|
||||
# --- раскладка геймпада в InputMap ---
|
||||
var want := {"jump": 2, "sprint": 2, "crouch": 1, "dash": 2, "zoom": 2, "ready": 1, "leaderboard": 1, "giveup": 1, "pause": 2,
|
||||
"move_forward": 2, "look_left": 1, "look_down": 1}
|
||||
for a in want:
|
||||
var pads := 0
|
||||
for e in InputMap.action_get_events(a):
|
||||
if e is InputEventJoypadButton or e is InputEventJoypadMotion:
|
||||
pads += 1
|
||||
check("геймпад: %s привязан (%d)" % [a, pads], pads >= 1 and (a != "jump" and a != "dash" or pads == 2))
|
||||
|
||||
# --- пауза ---
|
||||
check("меню закрыто в начале", not PauseMenu.open)
|
||||
act("pause")
|
||||
await frames(3)
|
||||
act("pause", false)
|
||||
check("Esc/Start открывает меню", PauseMenu.open)
|
||||
check("игрок заблокирован в меню", Net.input_blocked)
|
||||
check("в хабе кнопки «Выйти в хаб» нет", not PauseMenu._hub_btn.visible)
|
||||
act("pause")
|
||||
await frames(3)
|
||||
act("pause", false)
|
||||
check("повторный Esc закрывает меню", not PauseMenu.open)
|
||||
check("после закрытия игрок свободен", not Net.input_blocked)
|
||||
|
||||
# --- настройки: мышь, инверсия, FOV ---
|
||||
Settings.mouse_sens = 1.0
|
||||
Settings.invert_y = false
|
||||
p.rotation.y = 0.0
|
||||
p.head.rotation.x = 0.0
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
var m := Vector2(100, 50) # мышь в headless не «захвачена», поэтому зовём расчёт обзора напрямую
|
||||
p._mouse_look(m)
|
||||
var yaw1: float = absf(p.rotation.y)
|
||||
var pitch1: float = p.head.rotation.x
|
||||
Settings.mouse_sens = 2.0
|
||||
p.rotation.y = 0.0
|
||||
p.head.rotation.x = 0.0
|
||||
p._mouse_look(m)
|
||||
check("чувствительность ×2 удваивает поворот (%.3f -> %.3f)" % [yaw1, absf(p.rotation.y)], absf(absf(p.rotation.y) / yaw1 - 2.0) < 0.05)
|
||||
Settings.invert_y = true
|
||||
p.head.rotation.x = 0.0
|
||||
p._mouse_look(m)
|
||||
check("инверсия меняет знак наклона (%.3f и %.3f)" % [pitch1, p.head.rotation.x], pitch1 != 0.0 and signf(p.head.rotation.x) == -signf(pitch1))
|
||||
Settings.invert_y = false
|
||||
Settings.mouse_sens = 1.0
|
||||
Settings.fov = 90.0
|
||||
await frames(3)
|
||||
check("FOV из настроек применяется (%.0f)" % p.base_fov, is_equal_approx(p.base_fov, 90.0))
|
||||
Settings.fov = Settings.DEFAULT_FOV
|
||||
|
||||
# --- обзор правым стиком ---
|
||||
p.rotation.y = 0.0
|
||||
Input.action_press("look_right", 1.0)
|
||||
await frames(10)
|
||||
Input.action_release("look_right")
|
||||
check("правый стик поворачивает обзор (%.2f рад)" % p.rotation.y, p.rotation.y < -0.1)
|
||||
var before: float = p.rotation.y
|
||||
Net.input_blocked = true
|
||||
Input.action_press("look_right", 1.0)
|
||||
await frames(5)
|
||||
Input.action_release("look_right")
|
||||
Net.input_blocked = false
|
||||
check("в меню стик обзор не крутит", is_equal_approx(p.rotation.y, before))
|
||||
|
||||
# --- кнопки геймпада доходят до действий ---
|
||||
var b := InputEventJoypadButton.new()
|
||||
b.button_index = JOY_BUTTON_A
|
||||
b.pressed = true
|
||||
Input.parse_input_event(b)
|
||||
await frames(2)
|
||||
check("кнопка A = прыжок", Input.is_action_pressed("jump"))
|
||||
b.pressed = false
|
||||
Input.parse_input_event(b)
|
||||
var t := InputEventJoypadMotion.new()
|
||||
t.axis = JOY_AXIS_TRIGGER_RIGHT
|
||||
t.axis_value = 1.0
|
||||
Input.parse_input_event(t)
|
||||
await frames(2)
|
||||
check("правый курок = спринт", Input.is_action_pressed("sprint"))
|
||||
t.axis_value = 0.0
|
||||
Input.parse_input_event(t)
|
||||
|
||||
# --- сохранение настроек ---
|
||||
Settings.mouse_sens = 1.7
|
||||
Settings.save()
|
||||
Settings.mouse_sens = 1.0
|
||||
Settings.load_settings()
|
||||
check("настройки читаются из файла", is_equal_approx(Settings.mouse_sens, 1.7))
|
||||
Settings.reset_defaults()
|
||||
Settings.save()
|
||||
|
||||
# --- выход в хаб с другой карты ---
|
||||
level.reparent(get_tree().root) # хаб — текущая сцена, чтобы смена сцены не убила сам тест
|
||||
get_tree().current_scene = level
|
||||
Net.current_map = "race"
|
||||
act("pause")
|
||||
await frames(3)
|
||||
act("pause", false)
|
||||
check("не в хабе кнопка «Выйти в хаб» видна", PauseMenu._hub_btn.visible)
|
||||
PauseMenu._go_hub()
|
||||
await frames(10)
|
||||
check("«Выйти в хаб» закрыло меню", not PauseMenu.open and not Net.input_blocked)
|
||||
check("«Выйти в хаб» открыло хаб (%s)" % get_tree().current_scene, get_tree().current_scene != null and get_tree().current_scene.name == "Hub")
|
||||
check("карта сменилась на hub", Net.current_map == "hub")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ck5rnfgqt7ndb
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/test/pause_test.gd" id="1"]
|
||||
|
||||
[node name="T" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,20 @@
|
||||
# Проверка карты Race 2 «по существу» после прохода через портал RACE 2 с настоящими пакетами (по образцу parkour_check.txt).
|
||||
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...")
|
||||
# Пакеты: 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:12,1,-15
|
||||
# (портал RACE 2 пятый в ряду: x = (4 - 2) * 4.5 = 9.0). Сервер в этом тесте не нужен.
|
||||
# Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=13, лазеров=10, вентиляторов=6, полос=15 (см. race2_data.gd), музыка_в_файле=true, сдача=true.
|
||||
|
||||
var sc := get_tree().current_scene
|
||||
if sc != null and sc.name == "Race2" and int(t) % 3 == 0:
|
||||
var shaded := 0
|
||||
for mi in sc.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.get_surface_override_material_count() > 0 and mi.get_surface_override_material(0) is ShaderMaterial:
|
||||
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(" ПРОВЕРКА Race2: скрипт=%s правила=%s шейдерных_мешей=%d небо_панорама=%s ворот=%d лазеров=%d вентиляторов=%d полос=%d музыка_в_файле=%s сдача=%s" % [
|
||||
sc.get_script() != null, sc.get_node_or_null("Rules") != null, shaded, sky_ok, sc._gates.size(), sc._lasers.size(),
|
||||
sc.find_children("*", "Area3D", false, false).filter(func(a): return a.get_script() != null and a.get_script().resource_path.ends_with("vent.gd")).size(),
|
||||
sc.find_children("*", "Area3D", false, false).filter(func(a): return a.get_script() != null and a.get_script().resource_path.ends_with("boost_strip.gd")).size(),
|
||||
ResourceLoader.exists("res://maps/race2/audio/music.ogg"), sc.get_node("Rules").has_method("_give_up")])
|
||||
+97
-3
@@ -6,9 +6,15 @@ extends Node
|
||||
# --rounds=<сколько кругов пройти> --quit-race-time=<выйти из игры на этой секунде гонки>
|
||||
# --die-at-gate=<n>: после ворот n умереть (проверка возрождения лицом по трассе)
|
||||
# --fall-at-gate=<n>: после ворот n упасть в пустоту (проверка срыва ниже ворот)
|
||||
# --gu1=<план>, --gu2=<план>: сдача в круге 1 / 2. План: «cancel@1,giveup@2» — когда пройдено столько ворот,
|
||||
# держать G 1,0 с и отпустить (отмена) / держать до сдачи (проверка race_giveup_test.sh)
|
||||
# --map=race2: карта «Гонка 2». Бежать по её маршруту без физики бегуна нельзя (вентиляторы, лазеры, стены), поэтому бот
|
||||
# «проводит» игрока через ворота телепортами по ~0,35 м за кадр (меньше TELEPORT_JUMP: правила считают это бегом); проверяются правила, а не проходимость
|
||||
# (проходимость карты — боты в Blender models/race_02/tests/).
|
||||
# Факты печатаются строками «[имя] …»; сверяет их tools/test/race_game_test.sh.
|
||||
|
||||
const Data = preload("res://maps/race/race_data.gd")
|
||||
var Data = null # данные карты: race_data.gd или (--map=race2) race2_data.gd
|
||||
var map_id := "race"
|
||||
|
||||
var label := "?"
|
||||
var delay := 0.0
|
||||
@@ -36,9 +42,15 @@ 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():
|
||||
if a.begins_with("--map="): map_id = a.substr(6)
|
||||
if a.begins_with("--name="): label = a.substr(7)
|
||||
if a.begins_with("--delay="): delay = float(a.substr(8))
|
||||
if a.begins_with("--expect="): expect = int(a.substr(9))
|
||||
@@ -46,6 +58,14 @@ 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])})
|
||||
if map_id == "race2":
|
||||
Data = load("res://maps/race2/race2_data.gd")
|
||||
else:
|
||||
Data = load("res://maps/race/race_data.gd")
|
||||
route = JSON.parse_string(FileAccess.get_file_as_string("res://tools/test/race_route.json")).safe
|
||||
|
||||
func gp(w: Array) -> Vector3:
|
||||
@@ -65,7 +85,7 @@ func say(text: String) -> void:
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
var sc := get_tree().current_scene
|
||||
if sc == null or sc.name != "Race":
|
||||
if sc == null or sc.name != ("Race2" if map_id == "race2" else "Race"):
|
||||
return
|
||||
rules = sc.get_node_or_null("Rules")
|
||||
p = get_tree().get_first_node_in_group("local_player")
|
||||
@@ -117,7 +137,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 +163,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:
|
||||
@@ -177,7 +229,49 @@ func _race() -> void:
|
||||
return
|
||||
_drive()
|
||||
|
||||
func _drive_tele() -> void:
|
||||
var g: int = rules.passed.get(rules.my_id, 0)
|
||||
if g >= Data.CHECKPOINTS.size() or p.is_dead:
|
||||
return
|
||||
var gate: Array = Data.CHECKPOINTS[g]
|
||||
var yaw := deg_to_rad(gate[2])
|
||||
var fwd := Vector3(-sin(yaw), 0, -cos(yaw))
|
||||
var tgt: Vector3 = gate[0] + fwd * 1.5
|
||||
var step := 0.35 # м за кадр (~21 м/с): круг ~60 с, как у настоящей гонки — иначе зритель C не успеет зайти в разгар круга
|
||||
# лазерный мост: идём медленно (12 м/с) и на ближних подступах к воротам выбираем полосу, где лазер выключен в момент пролёта
|
||||
var pos: Vector3 = p.global_position
|
||||
var lane := -1.0
|
||||
if pos.x > 105.0 and pos.x < 115.0 and pos.z < -535.0 and pos.z > -615.0:
|
||||
step = 0.2
|
||||
for l in Data.LASERS:
|
||||
if l[0].x < 110.0: # левая половина; правая — в паре
|
||||
var dz: float = pos.z - l[0].z # > 0 — ворота ещё впереди (идём в -Z)
|
||||
if dz > -1.0 and dz < 8.0:
|
||||
var t := Time.get_unix_time_from_system() + maxf(dz, 0.0) / 12.0
|
||||
var left_on: bool = fposmod(t + l[5], l[3]) < l[4] or fposmod(t + 0.4 + l[5], l[3]) < l[4] or fposmod(t - 0.4 + l[5], l[3]) < l[4]
|
||||
var right_on: bool = fposmod(t + 1.5 + l[5], l[3]) < l[4] or fposmod(t + 1.9 + l[5], l[3]) < l[4] or fposmod(t + 1.1 + l[5], l[3]) < l[4]
|
||||
lane = 112.0 if left_on else 108.0
|
||||
if left_on and right_on:
|
||||
lane = -2.0 # обе полосы заняты — стоим
|
||||
if lane == -2.0:
|
||||
return
|
||||
if lane > 0.0:
|
||||
p.global_position.x = lane
|
||||
pos.x = lane
|
||||
tgt.x = lane
|
||||
var d: Vector3 = tgt - pos
|
||||
if tgt.y - pos.y > 2.0: # ворота выше (крыша башни): сначала поднимаемся вертикально — иначе упрёмся в стену
|
||||
d = Vector3(0, minf(step, tgt.y - pos.y + 1.5), 0)
|
||||
elif d.length() > step:
|
||||
d = d.normalized() * step
|
||||
p.global_position = pos + d
|
||||
p.velocity = Vector3.ZERO
|
||||
p.rotation.y = yaw
|
||||
|
||||
func _drive() -> void:
|
||||
if map_id == "race2":
|
||||
_drive_tele()
|
||||
return
|
||||
var pos: Vector3 = p.global_position
|
||||
if fr % 120 == 0 and OS.get_cmdline_user_args().has("--bot-log"):
|
||||
say("бег: wi=%d поз=(%.1f, %.1f, %.1f) v=(%.1f, %.1f) пол=%s" % [wi, pos.x, pos.y, pos.z, p.velocity.x, p.velocity.z, p.is_on_floor()])
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Проверка карты Race «по существу» после прохода через портал RACE с настоящими пакетами (по образцу parkour_check.txt).
|
||||
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...")
|
||||
# Пакеты: 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
|
||||
# godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:6,1,-15
|
||||
# (портал 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")])
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
# Игровая проверка режима «Гонка» на локальном сервере: 2 клиента-бота (+ третий, который заходит в разгар круга 1,
|
||||
# наблюдает, участвует в круге 2 и выходит посреди него). Окно не открывается.
|
||||
# tools/test/race_game_test.sh [рабочая_папка=/tmp/race_game]
|
||||
# MAP=race2 — то же для карты «Гонка 2» (бот проводит игроков через ворота телепортами: проверяются правила).
|
||||
# Печатает факты и итог «ПРОВЕРКА ПРОЙДЕНА / ПРОВАЛЕНА». Порт сервера 9080 должен быть свободен.
|
||||
# Настоящий рекорд (user://race_best.cfg) не трогается: рекорд ботов идёт в файлы в рабочей папке.
|
||||
set -uo pipefail
|
||||
SRC="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
W="${1:-/tmp/race_game}"
|
||||
MAP="${MAP:-race}"
|
||||
if [ "$MAP" = race2 ]; then SCENE=res://maps/race2/race2.tscn; MARG="--map=race2"; else SCENE=res://maps/race/race.tscn; MARG=""; fi
|
||||
rm -rf "$W"; mkdir -p "$W"
|
||||
"$SRC/tools/test/make_test_server.sh" "$W/server" >/dev/null
|
||||
"$SRC/tools/test/make_test_project.sh" "$W/project" >/dev/null
|
||||
@@ -14,12 +17,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" $SCENE -- --client $MARG --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=$!
|
||||
|
||||
Executable
+97
@@ -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)
|
||||
@@ -0,0 +1,107 @@
|
||||
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.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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1i84ofimjgmp
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user