Compare commits

...
6 Commits
Author SHA1 Message Date
aibrashandClaude Sonnet 5.5 2d347d132b Геймпад Xbox, меню паузы с настройками, новый хаб с музыкой
- Геймпад: раскладка под паркур и гонки, обзор правым стиком, навигация по меню (A/B/крестовина)
- Меню паузы (Esc/Start): выход в хаб, настройки (мышь, стик, FOV, громкость, инверсия), справка по управлению
- Хаб 48x32x10 м: колонны, окна с городом, голограмма, лавки; музыка «Local Forecast - Elevator» (Kevin MacLeod, CC-BY)
- Тесты: pause_test, pad_menu_test, hub_tour; README обновлён

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:36:50 +05:00
aibrashandClaude Sonnet 5.5 0ffba7311f Race 2: шахта-лифт — низкая щель в левой стене (только подкат/присед), внутри 3 x 3,5 м
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:21:07 +05:00
aibrashandClaude Sonnet 5.5 5d8b2165e3 Race 2: glb пересобран (прошлый экспорт молча падал из-за переменной в bake.py), шахта без знака
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:21:07 +05:00
aibrashandClaude Sonnet 5.5 3d017ffdca Race 2: шахта-лифт уже (окно 2,6 x 2,4 м, внутри 4 x 5 м), без рекламы у башни
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:21:07 +05:00
aibrashandClaude Sonnet 5.5 86a1821d27 Race 2: своя палитра и небо, чистовое запекание, вентиляторы сильнее, скрытая шахта, новая песня
- палитра целиком (индиго, пурпур, ледяная бирюза): SOLID/NEON, SURFACES/GLOWS, туман, ambient, свет запекания, ворота, полосы, надписи; новое небо
- запекание 384 сэмпла + огни у подножия башни
- вентилятор: VENT_MAX_RISE 16, VENT_ACCEL 80; шахта-лифт без знака и без столба/света (hidden)
- музыка Race 2: Poloz «On My Way to Hell» с 1:40

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:21:07 +05:00
aibrashandClaude Sonnet 5.5 c3e0ae40b2 Race 2 «Верхние ярусы», фаза 1: карта, вентиляторы, ускоряющие полосы, правила общие с Race
- maps/race2/: сцена, race2.gd (ворота со световыми столбами, лазеры, вентиляторы, полосы, срыв, дождь, гул), данные и модель из Blender models/race_02
  (раскладка layout.py -> build_race.py -> bake.py; свет пока запечён грубо, небо и музыка — копии Race)
- objects/vent.gd, objects/boost_strip.gd + хуки apply_vent()/apply_boost() в player/player.gd (MAX_SPEED и остальная физика не тронуты)
- race_rules.gd берёт данные у карты (общий код правил для Race и Race 2)
- портал RACE 2 в хабе, пресет экспорта Race2 + export_all.sh, Net.TIMED_MAPS/MAP_TITLES для race2 (RPC не менялись; серверу нужен deploy_server.sh)
- тесты: race_game_test.sh с MAP=race2 (бот проводит игроков телепортами), race2_check.txt
- README: раздел «Карта Race 2», результаты ботов по веткам

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:21:07 +05:00
50 changed files with 2241 additions and 51 deletions
+97 -12
View File
@@ -21,7 +21,8 @@
| | Что | | | Что |
|---|---| |---|---|
| ✅ | Хаб с порталами; карты **Песочница**, **Backrooms**, **Parkour** и **Race**; выход из карт обратно в хаб | | ✅ | Хаб с порталами; карты **Песочница**, **Backrooms**, **Parkour**, **Race** и **Race 2**; выход из карт обратно в хаб |
| 🔶 | **Race 2 «Верхние ярусы»** — вторая гонка (крыши и мосты над городом, бег по фасадам, лазерные мосты, спуск, башня; три пути на каждом участке; вентиляторы и ускоряющие полосы). **Фаза 1 готова** (раскладка, геометрия, проходимость ботами, правила); фаза 2 (чистовое запекание, своё небо и музыка) ждёт одобрения владельца — раздел «Карта Race 2» |
| ✅ | **Race** — гонка на ногах до 8 игроков (отсчёт, ворота по порядку, места, итоги, рекорд, песня на старте); **Parkour** — подъём по мегабашне с чекпоинтами, лазерами, дождём и песней; обе карты — мрачная киберпанк-антиутопия, свет запечён в Blender (см. «Карты и порталы», «Карта Race», «Заметки для будущего меня») | | ✅ | **Race** — гонка на ногах до 8 игроков (отсчёт, ворота по порядку, места, итоги, рекорд, песня на старте); **Parkour** — подъём по мегабашне с чекпоинтами, лазерами, дождём и песней; обе карты — мрачная киберпанк-антиутопия, свет запечён в Blender (см. «Карты и порталы», «Карта Race», «Заметки для будущего меня») |
| ✅ | Вкладка в браузере занимает **~300 МБ** (замер владельца, окт 2026) | | ✅ | Вкладка в браузере занимает **~300 МБ** (замер владельца, окт 2026) |
| ✅ | Карты качаются отдельными пакетами `.pck` при первом входе в портал (экран загрузки с прогрессом) | | ✅ | Карты качаются отдельными пакетами `.pck` при первом входе в портал (экран загрузки с прогрессом) |
@@ -31,10 +32,10 @@
| ✅ | Паркур: спринт, рывок, подкат, **присед (C)**, бег по стенам, прыжок от стены, двойной прыжок, подтягивание на уступ, зум (ЛКМ) | | ✅ | Паркур: спринт, рывок, подкат, **присед (C)**, бег по стенам, прыжок от стены, двойной прыжок, подтягивание на уступ, зум (ЛКМ) |
| ✅ | Управление **не зависит от раскладки** (русская и любая другая): действия `InputMap` на физические клавиши | | ✅ | Управление **не зависит от раскладки** (русская и любая другая): действия `InputMap` на физические клавиши |
| ✅ | Деплой: CI из `grid-world` на сайт; скрипты экспорта, dev-сервера, тестов без окна, выкладки сервера | | ✅ | Деплой: CI из `grid-world` на сайт; скрипты экспорта, dev-сервера, тестов без окна, выкладки сервера |
| ❌ | Управление с помощью геймпада | | ✅ | **Геймпад Xbox** (раскладка под паркур и гонки, обзор правым стиком), **меню паузы** по Esc/Start (выход в хаб, настройки, справка), **настройки** (мышь, стик, FOV, громкость, инверсия) — разделы «Управление», «Меню паузы и настройки». В браузере с живым геймпадом не проверялось (только тест без окна) |
| ✅ | **Ники** (панель по N, подпись над игроками, в итогах гонки) и **общий рейтинг** лучших времён на сервере (топ-10 на Race/Parkour, таблица по Tab, доски в хабе, таймер и итоги Parkour, сдача в гонке на G) — раздел «Ники и рейтинг» | | ✅ | **Ники** (панель по N, подпись над игроками, в итогах гонки) и **общий рейтинг** лучших времён на сервере (топ-10 на Race/Parkour, таблица по Tab, доски в хабе, таймер и итоги Parkour, сдача в гонке на G) — раздел «Ники и рейтинг» |
| ❌ | Текстовый чат — **запланирован**, не сделан (план в «Дорожная карта → Ближайшее») | | ❌ | Текстовый чат — **запланирован**, не сделан (план в «Дорожная карта → Ближайшее») |
| ❌ | Настроек (чувствительность мыши, громкость), меню паузы | | ✅ | Хаб увеличен (48×32×10 м) и оформлен, в нём играет музыка «как в лифте» — раздел «Карты и порталы», «Лицензии» |
| ❌ | Серверных проверок (антиспуфинг позиций, античит): сервер только пересылает | | ❌ | Серверных проверок (антиспуфинг позиций, античит): сервер только пересылает |
| ❌ | Сохранения прогресса, аккаунтов | | ❌ | Сохранения прогресса, аккаунтов |
| ❌ | Автоматических тестов в CI (есть боты для ручного прогона, см. «Тесты без окна») | | ❌ | Автоматических тестов в CI (есть боты для ручного прогона, см. «Тесты без окна») |
@@ -57,7 +58,7 @@ godot --path . # можно запустить несколько ок
## Управление ## Управление
Клавиши привязаны к **физическому расположению** (так работает и на русской раскладке). Действия лежат в Клавиши привязаны к **физическому расположению** (так работает и на русской раскладке). Действия лежат в
`project.godot → [input]`: `move_forward/back/left/right`, `jump`, `sprint`, `crouch`, `dash`, `ready` (R — «готов» в гонке, перезапуск забега в Parkour), `nick` (N), `leaderboard` (Tab), `giveup` (G). `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` (правый стик) и все кнопки геймпада.
| Клавиша | Действие | | Клавиша | Действие |
|---|---| |---|---|
@@ -72,9 +73,25 @@ godot --path . # можно запустить несколько ок
| N | ввод/смена ника (панель по центру; Enter — подтвердить, Esc — пропустить/отмена); на время ввода игрок стоит | | N | ввод/смена ника (панель по центру; Enter — подтвердить, Esc — пропустить/отмена); на время ввода игрок стоит |
| Tab | таблица лидеров текущей карты (топ-10 и «Вы: N-й»); в хабе и на картах без рейтинга — вкладки по картам, A/D или стрелки; повторный Tab или 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` закрывает вкладку.
## Структура проекта ## Структура проекта
@@ -87,6 +104,8 @@ net/ сеть. Net — автолоад: и клиент, и
server.tscn пустая сцена для запуска выделенного сервера server.tscn пустая сцена для запуска выделенного сервера
core/ core/
map_loader.gd автолоад MapLoader: скачивает пакет карты и подключает его (см. «Загрузка карт») map_loader.gd автолоад MapLoader: скачивает пакет карты и подключает его (см. «Загрузка карт»)
settings.gd автолоад Settings: настройки (user://settings.cfg) и раскладка геймпада в InputMap
pause_menu.gd автолоад PauseMenu: меню паузы Esc/Start (см. «Меню паузы и настройки»)
overlay.gd автолоад Overlay: панель ника (N) и таблица лидеров (Tab) поверх любой карты overlay.gd автолоад Overlay: панель ника (N) и таблица лидеров (Tab) поверх любой карты
lb_view.gd LbView: таблица рейтинга карты (панель Tab, итоги Race/Parkour) и общий стиль панелей lb_view.gd LbView: таблица рейтинга карты (панель Tab, итоги Race/Parkour) и общий стиль панелей
player/ player/
@@ -94,15 +113,18 @@ player/
character/ character.glb — модель киберниндзя и 14 анимаций (исходник и скрипт сборки: `../Blender models/character_01/`) character/ character.glb — модель киберниндзя и 14 анимаций (исходник и скрипт сборки: `../Blender models/character_01/`)
objects/ objects/
portal.gd портал на другую карту (Area3D, рисуется кодом) portal.gd портал на другую карту (Area3D, рисуется кодом)
vent.gd вентиляционный поток (Area3D): тянет игрока вверх, горизонтальная скорость сохраняется (Race 2)
boost_strip.gd ускоряющая полоса (Area3D): добирает скорость до MAX_SPEED вдоль полосы (Race 2)
chasers/ chasers/
chaser.gd общий «мозг» преследователя: навмеш, зрение, погоня, блуждание, поимка, синхронизация chaser.gd общий «мозг» преследователя: навмеш, зрение, погоня, блуждание, поимка, синхронизация
rock/ камень: rock.tscn, Rock.glb (+ извлечённая текстура), Guitar.ogg rock/ камень: rock.tscn, Rock.glb (+ извлечённая текстура), Guitar.ogg
verity/ Верити: verity.tscn, verity2.glb (+ текстура), verity.ogg (звук из видео) verity/ Верити: verity.tscn, verity2.glb (+ текстура), verity.ogg (звук из видео)
maps/ у каждой карты своя папка — она самодостаточна и экспортируется отдельным пакетом 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, sandbox/ песочница: sandbox.tscn, forest.gd, birds.gd, levitate.gd, nav_bake.gd,
floor/ sky/ audio/ models/ (здания, башня, обелиск, деревья, Эмпайр-стейт…) floor/ sky/ audio/ models/ (здания, башня, обелиск, деревья, Эмпайр-стейт…)
race/ race.tscn, race.gd, race_rules.gd (правила гонки, сдача, итоги, рейтинг), race_data.gd (генерируется), baked.gdshader, 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 (генерируется), parkour/ parkour.tscn, parkour.gd (логика: чекпоинты, лазеры, дождь, звук, окружение), parkour_data.gd (генерируется),
baked.gdshader, parkour.glb (из Blender), sky.png (панорама), audio/music.ogg; см. «Заметки для будущего меня» baked.gdshader, parkour.glb (из Blender), sky.png (панорама), audio/music.ogg; см. «Заметки для будущего меня»
backrooms/ backrooms.tscn, backrooms.gd, backrooms_nav.res (запечённый навмеш), backrooms/ backrooms.tscn, backrooms.gd, backrooms_nav.res (запечённый навмеш),
@@ -126,11 +148,12 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
| Карта | Сцена | Пакет | Что там | | Карта | Сцена | Пакет | Что там |
|---|---|---|---| |---|---|---|---|
| Хаб | `maps/hub/hub.tscn` | в основном пакете | Комната 28×18×7 м, строится кодом (пол-грид, стены, свет). Порталы в ряд по центру, влезает 6. Над порталами карт «на время» — доски с топ-3 (см. «Ники и рейтинг»). Безопасна, преследователей нет | | Хаб | `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» возвращает в хаб | | Песочница | `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» в дальнем конце ведёт в хаб. Гул ламп генерируется кодом | | Backrooms | `maps/backrooms/backrooms.tscn` | `backrooms.pck` | Модель «Backrooms VR» (×1,4). Преследователь: **Верити** (масштаб 0,5). Выход «EXIT» в дальнем конце ведёт в хаб. Гул ламп генерируется кодом |
| Parkour | `maps/parkour/parkour.tscn` | `parkour.pck` (7,4 МБ: небо 3,5 + модель 3,4 + музыка 1,1) | Мрачная антиутопия в духе Ghostrunner: дождь, дымный воздух, грязный мокрый бетон, натриевый оранжевый, тревожный красный и больной зелёный свет, надписи-приказы (COMPLY, CURFEW 22:00, RESTRICTED). **Забег на время** (старт — линия на краю стартовой платформы, финиш — вершина; рейтинг на сервере; R — заново). Подъём по мегабашне, ~700 м, 9 чекпоинтов (светящиеся ворота), без преследователей. Секции: крыши (одинарный/двойной прыжок, подтягивание) → каньон из четырёх стен для бега → башня из 9 уступов (двойной прыжок + подтягивание) → лазерный зал (подкат, прыжок, тайминг, рывок) → пропасть из 6 контейнеров (зазоры 16–24 м) → спираль вокруг светящегося ядра (+24 м) → вершина с порталом в хаб. Срыв ниже последнего чекпоинта на 32 м или касание лазера = «СОРВАЛСЯ!» и возрождение на чекпоинте. Музыка: `maps/parkour/audio/music.ogg` (Ogg Vorbis, моно 32 кГц, ~1 МБ; делается `ffmpeg -i исходник.mp3 -ac 1 -ar 32000 -c:a libvorbis -q:a 0 music.ogg`), играет по кругу | | 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 | `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/`): **Правка и пересборка Parkour** (всё в `Blender models/parkour_02/`):
1. `layout.py` — единственный источник: плиты, стены, лазеры, чекпоинты и расчёт зазоров под мувсет (замеры ботом: прыжок на спринте 15 м, двойной 25 м, прыжок+рывок 19 м, двойной+рывок 28 м, бег по стене 1,8 с / 23 м, подтягивание на уступ до ~2,6 м; **стенами высоту не набрать** — прыжков от стены без бега только 2 за полёт). 1. `layout.py` — единственный источник: плиты, стены, лазеры, чекпоинты и расчёт зазоров под мувсет (замеры ботом: прыжок на спринте 15 м, двойной 25 м, прыжок+рывок 19 м, двойной+рывок 28 м, бег по стене 1,8 с / 23 м, подтягивание на уступ до ~2,6 м; **стенами высоту не набрать** — прыжков от стены без бега только 2 за полёт).
@@ -190,6 +213,41 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
**Выкладка (общее правило):** старый клиент с новым сервером (и наоборот) **не работает вообще** — 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`. **Выкладка (общее правило):** старый клиент с новым сервером (и наоборот) **не работает вообще** — 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=путь`. **Профиль** — `user://profile.cfg` (ConfigFile, секция `profile`): `client_id` (случайный 128-битный hex, 32 символа; создаётся при первом запуске, **не показывается**, опознаёт игрока в рейтинге; потерял файл/очистил данные сайта — стал новым игроком), `nick`, `asked` (уже спрашивали ник). В тестах путь задаётся `-- --profile=путь`.
@@ -223,6 +281,16 @@ const MAX_RECORD_TIME := 3600.0
- `parkour_run_test.py` — забег: линия, таймер, смерть, финиш, отказ слишком быстрому времени, R, запись в рейтинге. - `parkour_run_test.py` — забег: линия, таймер, смерть, финиш, отказ слишком быстрому времени, R, запись в рейтинге.
- `ui_tour.tscn --mode=hub|race|parkour` — кадры (`--write-movie`; рейтинг подставлен, сервер не нужен; добавляйте `--server-url=ws://127.0.0.1:1`, иначе окно подключится к реальному серверу и затрёт подставленное). - `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`).
## Преследователи ## Преследователи
`chasers/chaser.gd` — один скрипт для всех. Сцены (`rock.tscn`, `verity.tscn`) — это просто модель + звук + `NavigationAgent3D`. `chasers/chaser.gd` — один скрипт для всех. Сцены (`rock.tscn`, `verity.tscn`) — это просто модель + звук + `NavigationAgent3D`.
@@ -274,7 +342,7 @@ const MAX_RECORD_TIME := 3600.0
## Загрузка карт пакетами (веб) ## Загрузка карт пакетами (веб)
Основной пакет игры (`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)`: Карты — отдельные `.pck`: `world.pck` (13,6 МБ), `backrooms.pck` (21,3 МБ). Портал вызывает `MapLoader.enter(map, scene, pack, title)`:
1. если сцена уже есть в проекте (редактор, десктоп, пакет уже скачан) — переход сразу; 1. если сцена уже есть в проекте (редактор, десктоп, пакет уже скачан) — переход сразу;
@@ -319,14 +387,16 @@ python3 tools/serve_web.py ../grid-world 8080 # nginx-подобно: COOP/C
godot --headless --path /tmp/ts & # сервер 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=A &
godot --headless --path /tmp/tp res://maps/sandbox/sandbox.tscn -- --client --name=B 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 &) /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`). Для картинок: `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`. В браузере: 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): **Готовые проверки** (`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` — облёт карты со снимками (см. заголовок файла). - `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`/шейдер/музыку/логику). - **Проверяй по существу, а не по имени сцены.** Для карт из пакетов: собери тестовый проект (`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`. - Ввод ботом: `Input.action_press("jump")` работает для зажатия и `is_action_pressed`; рывок и подкат ловятся в `_unhandled_input`, поэтому для них нужно `Input.parse_input_event(InputEventAction)` с `pressed=true`.
@@ -420,6 +490,16 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
- В `--write-movie` с `canvas_items`+`expand` видимая область шире окна, и подписи у левого края обрезаются; HUD слева отступает на 64 px. - В `--write-movie` с `canvas_items`+`expand` видимая область шире окна, и подписи у левого края обрезаются; HUD слева отступает на 64 px.
- Кадры гонки: `race_tour.tscn --mode=hud` (R, отсчёт, пробег, итоги). Подробности — раздел «Карта Race». - Кадры гонки: `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.
## Дорожная карта ## Дорожная карта
### Телефон — не планируется ### Телефон — не планируется
@@ -469,6 +549,8 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
## Журнал изменений (окт 2026) ## Журнал изменений (окт 2026)
Последнее (31 окт 2026): геймпад Xbox, меню паузы с выходом в хаб и настройками, увеличенный и оформленный хаб с музыкой. Подробности — «Управление», «Меню паузы и настройки», «Карты и порталы». Выкладка сервера **не нужна** (`net/*` не менялся).
Сделано одной серией работ (хронологически): Сделано одной серией работ (хронологически):
1. **Персонаж:** модель киберниндзя со скелетом (752 треугольника, 14 анимаций, IK ног) вместо капсул; цвет свечения от id игрока; свой персонаж спрятан от своей камеры. Сальто камеры при двойном прыжке делалось и убрано по просьбе. 1. **Персонаж:** модель киберниндзя со скелетом (752 треугольника, 14 анимаций, IK ног) вместо капсул; цвет свечения от id игрока; свой персонаж спрятан от своей камеры. Сальто камеры при двойном прыжке делалось и убрано по просьбе.
2. **Подтягивание на уступы** починено (не работало от ~2 м на всех картах), замеры мувсета и тестовые сцены в `tools/test/`. 2. **Подтягивание на уступы** починено (не работало от ~2 м на всех картах), замеры мувсета и тестовые сцены в `tools/test/`.
@@ -479,8 +561,11 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
7. **Ники, общий рейтинг, таблица Tab, доски в хабе, сдача в гонке (G), таймер Parkour**, название «Aman's World» (заставка, иконка). 7. **Ники, общий рейтинг, таблица Tab, доски в хабе, сдача в гонке (G), таймер Parkour**, название «Aman's World» (заставка, иконка).
8. **Карта Metro (гонка вниз 1495 м со скользящими рельсами и бустером) сделана и УДАЛЕНА по решению владельца** («полный мусор», окт 2026): не понравилась. Из истории не восстанавливай без просьбы. Что узнали: (а) владельцу важнее всего скорость, маневренность и ощущение потока, как у первой гонки Race; (б) прежде чем отдавать большую карту агентам, показать владельцу концепцию и кадры черновика; (в) идея «скользящий рельс» (подхват в воздухе, езда 36 м/с, сход прыжком) работала технически, но потолок `MAX_SPEED = 22` обрезает скорость на сходе; (г) слишком светлые «пятнистые» залы из-за запекания и белые засветки — следить за силой света при запекании. 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`). - Модель карты 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`). При публикации нужно указывать автора.
- Остальные модели и звуки — материалы владельца проекта. - Остальные модели и звуки — материалы владельца проекта.
+331
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cyvtp5hwd4h5o
+147
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bkyvk0c3jox51
+51 -1
View File
@@ -10,7 +10,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" 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" export_path="../grid-world/index.html"
patches=PackedStringArray() patches=PackedStringArray()
patch_delta_encoding=false patch_delta_encoding=false
@@ -250,3 +250,53 @@ progressive_web_app/icon_512x512=""
progressive_web_app/background_color=Color(0, 0, 0, 1) progressive_web_app/background_color=Color(0, 0, 0, 1)
threads/emscripten_pool_size=8 threads/emscripten_pool_size=8
threads/godot_pool_size=4 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
+7
View File
@@ -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.
+19
View File
@@ -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
+49 -14
View File
@@ -1,20 +1,26 @@
extends Node3D extends Node3D
# Стартовая комната: ничего тяжёлого, только пол, стены и порталы на карты. Сами карты качаются # Стартовая комната: ничего тяжёлого, только пол, стены и порталы на карты. Сами карты качаются
# отдельными пакетами при входе в портал (World/map_loader.gd) — поэтому игра стартует быстро. # отдельными пакетами при входе в портал (World/map_loader.gd) — поэтому игра стартует быстро.
# Чтобы добавить карту — допиши запись в PORTALS: порталы встанут в ряд по центру (влезает 6). # Чтобы добавить карту — допиши запись в PORTALS: порталы встанут в ряд по центру (влезает 7).
# Оформление (колонны, окна, голограмма, лавки) — hub_decor.gd; музыка «как в лифте» играет по кругу.
const PORTALS := [ const PORTALS := [
{"title": "SANDBOX", "map": "world", "scene": "res://maps/sandbox/sandbox.tscn", "pack": "world.pck", "color": Color(0.2, 0.9, 1.0)}, {"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": "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": "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", "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_WIDTH := 48.0
const ROOM_DEPTH := 18.0 const ROOM_DEPTH := 32.0
const ROOM_HEIGHT := 7.0 # +1 м: над порталами доски рейтинга, над ними — «CHOOSE A MAP» const ROOM_HEIGHT := 10.0 # над порталами доски рейтинга, над ними — «CHOOSE A MAP»
const WALL := 0.5 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 := """ const GRID_SHADER := """
shader_type spatial; shader_type spatial;
@@ -36,6 +42,8 @@ func _ready() -> void:
_build_portals() _build_portals()
_build_environment() _build_environment()
_build_boards() _build_boards()
_build_decor()
_start_music()
if not Net.nick_asked: if not Net.nick_asked:
Overlay.open_nick.call_deferred(true) # первый запуск: спрашиваем ник Overlay.open_nick.call_deferred(true) # первый запуск: спрашиваем ник
@@ -44,11 +52,14 @@ func _build_room() -> void:
body.name = "Room" body.name = "Room"
add_child(body) add_child(body)
var floor_mat := ShaderMaterial.new() var decor := DecorScript.new()
floor_mat.shader = Shader.new() decor.name = "Decor"
floor_mat.shader.code = GRID_SHADER add_child(decor)
var floor_mat := decor.floor_material()
var wall_mat := StandardMaterial3D.new() 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 wall_mat.roughness = 1.0
var ceiling_mat := StandardMaterial3D.new() var ceiling_mat := StandardMaterial3D.new()
ceiling_mat.albedo_color = Color(0.05, 0.055, 0.08) ceiling_mat.albedo_color = Color(0.05, 0.055, 0.08)
@@ -101,7 +112,8 @@ func _build_portals() -> void:
label.font_size = 64 label.font_size = 64
label.outline_size = 12 label.outline_size = 12
label.modulate = Color(0.7, 0.9, 1.0) 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) add_child(label)
# ---------- доски рейтинга над порталами карт «на время» ---------- # ---------- доски рейтинга над порталами карт «на время» ----------
@@ -205,15 +217,38 @@ func _build_environment() -> void:
env.background_color = Color(0.02, 0.025, 0.04) env.background_color = Color(0.02, 0.025, 0.04)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.5, 0.58, 0.75) 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() var we := WorldEnvironment.new()
we.environment = env we.environment = env
add_child(we) add_child(we)
var lamp := OmniLight3D.new() var lamp := OmniLight3D.new()
lamp.position = Vector3(0, ROOM_HEIGHT - 0.6, 2.0) lamp.position = Vector3(0, ROOM_HEIGHT - 0.8, -6.0)
lamp.omni_range = 22.0 lamp.omni_range = 30.0
lamp.light_energy = 0.8 lamp.light_energy = 0.9
lamp.light_color = Color(0.8, 0.9, 1.0) lamp.light_color = Color(0.8, 0.9, 1.0)
lamp.shadow_enabled = false lamp.shadow_enabled = false
add_child(lamp) 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
View File
@@ -7,4 +7,4 @@
script = ExtResource("1_hub") script = ExtResource("1_hub")
[node name="Player" parent="." instance=ExtResource("2_player")] [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)
+356
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://or5cgrfhp73l
+2 -1
View File
@@ -9,7 +9,7 @@ extends Node
# Сдача (клавиша G, удержание): игрока возвращает на стартовую площадку, время не пишется ни в итоги, ни в рейтинг; # Сдача (клавиша G, удержание): игрока возвращает на стартовую площадку, время не пишется ни в итоги, ни в рейтинг;
# сдавшиеся считаются завершившими для конца круга. Рейтинг: финиш -> Net.submit_my_record(rating_id, время). # сдавшиеся считаются завершившими для конца круга. Рейтинг: финиш -> 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 } enum Phase { WAIT, COUNTDOWN, RACE, RESULTS }
@@ -70,6 +70,7 @@ var _giveup_bar: ProgressBar
func setup(m: Node3D) -> void: func setup(m: Node3D) -> void:
map = m map = m
player = m.player 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: if m.get("rating_id") != null:
rating_id = m.rating_id rating_id = m.rating_id
Binary file not shown.
+19
View File
@@ -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
+42
View File
@@ -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));
}
+1
View File
@@ -0,0 +1 @@
uid://cwrd86827arnc
+397
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://djwekkr3vawop
Binary file not shown.
+45
View File
@@ -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
+12
View File
@@ -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")]
+88
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c01qphr2te2ln
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

+41
View File
@@ -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
+2 -2
View File
@@ -28,8 +28,8 @@ const RACE_GIVEUP = 5 # игрок сдался (b: время гонки на
# Рейтинг лучших времён. Список карт «на время» и минимально допустимое время (страховка от мусора, не античит; # Рейтинг лучших времён. Список карт «на время» и минимально допустимое время (страховка от мусора, не античит;
# подкручивается здесь — см. README «Ники и рейтинг»). Сервер принимает только эти id; клиент по ним же решает, где рисовать доски. # подкручивается здесь — см. README «Ники и рейтинг»). Сервер принимает только эти id; клиент по ним же решает, где рисовать доски.
const TIMED_MAPS := {"race": {"min": 25.0}, "parkour": {"min": 40.0}} const TIMED_MAPS := {"race": {"min": 25.0}, "race2": {"min": 44.0}, "parkour": {"min": 40.0}}
const MAP_TITLES := {"race": "Гонка", "parkour": "Паркур", "hub": "Хаб", "world": "Песочница", "backrooms": "Backrooms"} const MAP_TITLES := {"race": "Гонка", "race2": "Гонка 2", "parkour": "Паркур", "hub": "Хаб", "world": "Песочница", "backrooms": "Backrooms"}
const MAX_RECORD_TIME := 3600.0 const MAX_RECORD_TIME := 3600.0
const BOARD_TOP := 10 # сколько строк уходит клиенту const BOARD_TOP := 10 # сколько строк уходит клиенту
const BOARD_KEEP := 100 # сколько записей на карту хранит сервер const BOARD_KEEP := 100 # сколько записей на карту хранит сервер
+57
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://7fr8utm135ud
+93
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://v76iegf4bft
+64 -9
View File
@@ -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 @export var MOUSE_SENSITIVITY: float = 0.003
# --- Настройки тряски камеры (Head Bobbing) --- # --- Настройки тряски камеры (Head Bobbing) ---
@@ -197,7 +207,8 @@ func _ready() -> void:
add_to_group("local_player") add_to_group("local_player")
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
head_base_pos = head.position head_base_pos = head.position
base_fov = head.fov base_fov = Settings.fov
head.fov = base_fov
# Тело видно в редакторе и отбрасывает тень, но своя камера его не рисует (слой 2) # Тело видно в редакторе и отбрасывает тень, но своя камера его не рисует (слой 2)
for mi: MeshInstance3D in $Model.find_children("*", "MeshInstance3D", true, false): for mi: MeshInstance3D in $Model.find_children("*", "MeshInstance3D", true, false):
@@ -235,8 +246,7 @@ func _setup_vignette() -> void:
rect.material = vignette_material rect.material = vignette_material
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"): # Esc / Start теперь открывают меню паузы (core/pause_menu.gd), оно само освобождает мышь
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not Net.input_blocked: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not Net.input_blocked:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # (панель ника открыта — мышь нужна на экране) Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # (панель ника открыта — мышь нужна на экране)
@@ -245,10 +255,7 @@ func _unhandled_input(event: InputEvent) -> void:
return return
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
var zoom_factor := head.fov / base_fov _mouse_look(event.relative)
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))
if input_frozen or Net.input_blocked: if input_frozen or Net.input_blocked:
return return
@@ -258,6 +265,32 @@ func _unhandled_input(event: InputEvent) -> void:
elif event.is_action_pressed("dash"): elif event.is_action_pressed("dash"):
_try_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_*) # Битовые флаги состояния для сетевой синхронизации (см. Net.FLAG_*)
func get_net_flags() -> int: func get_net_flags() -> int:
var flags := 0 var flags := 0
@@ -418,17 +451,39 @@ func _physics_process(delta: float) -> void:
if horizontal.length() > speed_cap: if horizontal.length() > speed_cap:
horizontal = horizontal.normalized() * 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: if horizontal.length() > MAX_SPEED:
horizontal = horizontal.normalized() * MAX_SPEED horizontal = horizontal.normalized() * MAX_SPEED
velocity.x = horizontal.x velocity.x = horizontal.x
velocity.z = horizontal.z 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. Камера # 6. Камера
_update_camera(delta, input_dir) _update_camera(delta, input_dir)
_move_and_track(delta) _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: func _move_and_track(delta: float) -> void:
var pre_vy := velocity.y 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 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) \ is_zooming = (Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED) \
and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED or (Input.is_action_pressed("zoom") and not Net.input_blocked)
# FOV: зум > рывок > слайд > стена > скорость бега # FOV: зум > рывок > слайд > стена > скорость бега
var target_fov := base_fov + sprint_factor * SPRINT_FOV_BONUS var target_fov := base_fov + sprint_factor * SPRINT_FOV_BONUS
+4 -3
View File
@@ -12,19 +12,20 @@ config_version=5
config/name="Aman's World" config/name="Aman's World"
run/main_scene="res://maps/hub/hub.tscn" run/main_scene="res://maps/hub/hub.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
config/use_custom_user_dir=true config/use_custom_user_dir=true
config/custom_user_dir_name="world2" config/custom_user_dir_name="world2"
config/icon="res://branding/icon.svg" config/features=PackedStringArray("4.7", "GL Compatibility")
boot_splash/bg_color=Color(0.031, 0.031, 0.039, 1) boot_splash/bg_color=Color(0.031, 0.031, 0.039, 1)
boot_splash/image="res://branding/splash.png" boot_splash/image="res://branding/splash.png"
boot_splash/fullsize=true config/icon="res://branding/icon.svg"
[autoload] [autoload]
Net="*res://net/net.gd" Net="*res://net/net.gd"
MapLoader="*res://core/map_loader.gd" MapLoader="*res://core/map_loader.gd"
Settings="*res://core/settings.gd"
Overlay="*res://core/overlay.gd" Overlay="*res://core/overlay.gd"
PauseMenu="*res://core/pause_menu.gd"
[display] [display]
+2 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Экспорт веб-клиента и пакетов карт. По умолчанию — в ../grid-world (репозиторий сайта). # Экспорт веб-клиента и пакетов карт. По умолчанию — в ../grid-world (репозиторий сайта).
# tools/export_all.sh [папка_вывода] # 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 set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
OUT="${1:-../grid-world}" 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 World "$OUT/world.pck"
godot --headless --export-pack Parkour "$OUT/parkour.pck" godot --headless --export-pack Parkour "$OUT/parkour.pck"
godot --headless --export-pack Race "$OUT/race.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}' ls -la "$OUT"/*.pck "$OUT"/index.wasm | awk '{printf "%7.2f МБ %s\n", $5/1e6, $9}'
+1 -1
View File
@@ -1,7 +1,7 @@
extends Node extends Node
# Тестовый бот (автозагрузка во ВРЕМЕННОЙ копии проекта, см. tools/test/make_test_project.sh). # Тестовый бот (автозагрузка во ВРЕМЕННОЙ копии проекта, см. 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> — телепорт игрока на этой секунде (например, прямо в портал). # --tp=<секунда>:<x>,<y>,<z> — телепорт игрока на этой секунде (например, прямо в портал).
# Остальные аргументы пользователя (--server-url=..., --pack-url=...) читают Net и MapLoader. # Остальные аргументы пользователя (--server-url=..., --pack-url=...) читают Net и MapLoader.
+34
View File
@@ -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])
+1
View File
@@ -0,0 +1 @@
uid://dndxmq38i6wyg
+6
View File
@@ -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")
+33
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://cgonoxtgka01j
+6
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# Проверка карты Parkour «по существу» после прохода через портал с настоящими пакетами. # Проверка карты Parkour «по существу» после прохода через портал с настоящими пакетами.
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...") # Вставь в 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 # и запусти: 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. # Ожидание (после рейтинга: таймер=true итоги=true рейтинг_класс=true): скрипт=true, шейдерных_мешей>=1, небо_панорама=true, музыка_играет=true, лазеров_и_ворот=10.
# Если в пакете не хватает файла, вместо этого будут false/0 (а имя сцены всё равно «Parkour»). # Если в пакете не хватает файла, вместо этого будут false/0 (а имя сцены всё равно «Parkour»).
+138
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://ck5rnfgqt7ndb
+6
View File
@@ -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")
+20
View File
@@ -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")])
+53 -2
View File
@@ -8,9 +8,13 @@ extends Node
# --fall-at-gate=<n>: после ворот n упасть в пустоту (проверка срыва ниже ворот) # --fall-at-gate=<n>: после ворот n упасть в пустоту (проверка срыва ниже ворот)
# --gu1=<план>, --gu2=<план>: сдача в круге 1 / 2. План: «cancel@1,giveup@2» — когда пройдено столько ворот, # --gu1=<план>, --gu2=<план>: сдача в круге 1 / 2. План: «cancel@1,giveup@2» — когда пройдено столько ворот,
# держать G 1,0 с и отпустить (отмена) / держать до сдачи (проверка race_giveup_test.sh) # держать 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. # Факты печатаются строками «[имя] …»; сверяет их 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 label := "?"
var delay := 0.0 var delay := 0.0
@@ -46,6 +50,7 @@ var gu_after_cancel := false
func _ready() -> void: func _ready() -> void:
for a in OS.get_cmdline_user_args(): 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("--name="): label = a.substr(7)
if a.begins_with("--delay="): delay = float(a.substr(8)) if a.begins_with("--delay="): delay = float(a.substr(8))
if a.begins_with("--expect="): expect = int(a.substr(9)) if a.begins_with("--expect="): expect = int(a.substr(9))
@@ -57,6 +62,10 @@ func _ready() -> void:
for e in a.substr(6).split(","): for e in a.substr(6).split(","):
var q := e.split("@") var q := e.split("@")
gu_plan[int(a.substr(4, 1))].append({"mode": q[0], "gate": int(q[1])}) 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 route = JSON.parse_string(FileAccess.get_file_as_string("res://tools/test/race_route.json")).safe
func gp(w: Array) -> Vector3: func gp(w: Array) -> Vector3:
@@ -76,7 +85,7 @@ func say(text: String) -> void:
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
var sc := get_tree().current_scene 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 return
rules = sc.get_node_or_null("Rules") rules = sc.get_node_or_null("Rules")
p = get_tree().get_first_node_in_group("local_player") p = get_tree().get_first_node_in_group("local_player")
@@ -220,7 +229,49 @@ func _race() -> void:
return return
_drive() _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: func _drive() -> void:
if map_id == "race2":
_drive_tele()
return
var pos: Vector3 = p.global_position var pos: Vector3 = p.global_position
if fr % 120 == 0 and OS.get_cmdline_user_args().has("--bot-log"): 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()]) 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 -1
View File
@@ -1,7 +1,7 @@
# Проверка карты Race «по существу» после прохода через портал RACE с настоящими пакетами (по образцу parkour_check.txt). # Проверка карты Race «по существу» после прохода через портал RACE с настоящими пакетами (по образцу parkour_check.txt).
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...") # Вставь в 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 &) и # Пакеты: 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). Сервер в этом тесте не нужен. # (портал RACE четвёртый в ряду: x = (3 - 1.5) * 4.5 = 6.75). Сервер в этом тесте не нужен.
# Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true, сдача=true. # Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true, сдача=true.
+4 -1
View File
@@ -2,11 +2,14 @@
# Игровая проверка режима «Гонка» на локальном сервере: 2 клиента-бота (+ третий, который заходит в разгар круга 1, # Игровая проверка режима «Гонка» на локальном сервере: 2 клиента-бота (+ третий, который заходит в разгар круга 1,
# наблюдает, участвует в круге 2 и выходит посреди него). Окно не открывается. # наблюдает, участвует в круге 2 и выходит посреди него). Окно не открывается.
# tools/test/race_game_test.sh [рабочая_папка=/tmp/race_game] # tools/test/race_game_test.sh [рабочая_папка=/tmp/race_game]
# MAP=race2 — то же для карты «Гонка 2» (бот проводит игроков через ворота телепортами: проверяются правила).
# Печатает факты и итог «ПРОВЕРКА ПРОЙДЕНА / ПРОВАЛЕНА». Порт сервера 9080 должен быть свободен. # Печатает факты и итог «ПРОВЕРКА ПРОЙДЕНА / ПРОВАЛЕНА». Порт сервера 9080 должен быть свободен.
# Настоящий рекорд (user://race_best.cfg) не трогается: рекорд ботов идёт в файлы в рабочей папке. # Настоящий рекорд (user://race_best.cfg) не трогается: рекорд ботов идёт в файлы в рабочей папке.
set -uo pipefail set -uo pipefail
SRC="$(cd "$(dirname "$0")/../.." && pwd)" SRC="$(cd "$(dirname "$0")/../.." && pwd)"
W="${1:-/tmp/race_game}" 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" rm -rf "$W"; mkdir -p "$W"
"$SRC/tools/test/make_test_server.sh" "$W/server" >/dev/null "$SRC/tools/test/make_test_server.sh" "$W/server" >/dev/null
"$SRC/tools/test/make_test_project.sh" "$W/project" >/dev/null "$SRC/tools/test/make_test_project.sh" "$W/project" >/dev/null
@@ -19,7 +22,7 @@ SERVER=$!
sleep 2 sleep 2
run() { # имя, остальные аргументы бота run() { # имя, остальные аргументы бота
local n="$1"; shift 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" --profile="$W/prof_$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 run A --expect=2 --rounds=2 --die-at-gate=3
PA=$! PA=$!