Карта Race: гонка на ногах по киберпанк-антиутопии, 8 игроков
- maps/race: трасса 633 м из Blender (три развилки: срез по контейнерам, каньон со стенами против нижней дороги, труба с лазерами против коридора), запечённый свет, небо, дождь, гул; фоновая песня (Ogg Vorbis, ~0,6 МБ) включается на «СТАРТ!» - Правила (race_rules.gd): R — готов; когда готовы все — отсчёт 6 с, заморозка в слотах, ворота строго по порядку, сплиты, место, итоги, личный рекорд (user://race_best.cfg) - Сеть: Net.submit_race / recv_race / race_event (RACE_READY, START, CP, FINISH, STATE). ВНИМАНИЕ: набор RPC изменён — клиент и сервер выкладываются вместе, старый клиент с новым сервером не работает (rpc node checksum failed) - player.gd: input_frozen и place() для режимов карт; project.godot: действие ready (R) - Портал RACE в хабе, пресет экспорта Race (include_filter для ресурсов, грузимых строкой) - tools/test: двухклиентный тест гонки на локальном сервере, бот, облёт, проверка пакета - README: правила гонки, события, проверки, заметки Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
This commit is contained in:
@@ -42,12 +42,12 @@ godot --path . # можно запустить несколько ок
|
|||||||
```
|
```
|
||||||
|
|
||||||
Карты в редакторе запускаются отдельно (F6 на сцене карты): `maps/sandbox/sandbox.tscn`,
|
Карты в редакторе запускаются отдельно (F6 на сцене карты): `maps/sandbox/sandbox.tscn`,
|
||||||
`maps/backrooms/backrooms.tscn`, `maps/parkour/parkour.tscn`. В редакторе и при запуске с диска порталы ведут на сцены напрямую (пакеты не качаются).
|
`maps/backrooms/backrooms.tscn`, `maps/parkour/parkour.tscn`, `maps/race/race.tscn`. В редакторе и при запуске с диска порталы ведут на сцены напрямую (пакеты не качаются).
|
||||||
|
|
||||||
## Управление
|
## Управление
|
||||||
|
|
||||||
Клавиши привязаны к **физическому расположению** (так работает и на русской раскладке). Действия лежат в
|
Клавиши привязаны к **физическому расположению** (так работает и на русской раскладке). Действия лежат в
|
||||||
`project.godot → [input]`: `move_forward/back/left/right`, `jump`, `sprint`, `crouch`, `dash`.
|
`project.godot → [input]`: `move_forward/back/left/right`, `jump`, `sprint`, `crouch`, `dash`, `ready` (R — «готов» в гонке).
|
||||||
|
|
||||||
| Клавиша | Действие |
|
| Клавиша | Действие |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -57,6 +57,7 @@ godot --path . # можно запустить несколько ок
|
|||||||
| C (держать) | **присед** (скорость 3,5, капсула вдвое ниже, пролезает под низкое) |
|
| C (держать) | **присед** (скорость 3,5, капсула вдвое ниже, пролезает под низкое) |
|
||||||
| Shift + C на бегу | **подкат** |
|
| Shift + C на бегу | **подкат** |
|
||||||
| Q | рывок |
|
| Q | рывок |
|
||||||
|
| R | «готов» (только на карте Race) |
|
||||||
| ЛКМ (зажать) | зум |
|
| ЛКМ (зажать) | зум |
|
||||||
| Esc / клик | отпустить / захватить мышь |
|
| Esc / клик | отпустить / захватить мышь |
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ maps/ у каждой карты своя папка — он
|
|||||||
hub/ стартовая комната: hub.tscn, hub.gd (порталы строятся из списка PORTALS)
|
hub/ стартовая комната: hub.tscn, hub.gd (порталы строятся из списка PORTALS)
|
||||||
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 (генерируется), race.glb, sky.png, audio/music.ogg
|
||||||
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 (запечённый навмеш),
|
||||||
@@ -112,6 +114,7 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
|
|||||||
| Песочница | `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). Подъём по мегабашне, ~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). Подъём по мегабашне, ~700 м, 9 чекпоинтов (светящиеся ворота), без преследователей. Секции: крыши (одинарный/двойной прыжок, подтягивание) → каньон из четырёх стен для бега → башня из 9 уступов (двойной прыжок + подтягивание) → лазерный зал (подкат, прыжок, тайминг, рывок) → пропасть из 6 контейнеров (зазоры 16–24 м) → спираль вокруг светящегося ядра (+24 м) → вершина с порталом в хаб. Срыв ниже последнего чекпоинта на 32 м или касание лазера = «СОРВАЛСЯ!» и возрождение на чекпоинте. Музыка: `maps/parkour/audio/music.ogg` (Ogg Vorbis, моно 32 кГц, ~1 МБ; делается `ffmpeg -i исходник.mp3 -ac 1 -ar 32000 -c:a libvorbis -q:a 0 music.ogg`), играет по кругу |
|
||||||
|
| Race | `maps/race/race.tscn` | `race.pck` | Гонка на ногах до 8 игроков (кто быстрее до финиша), мрачный город: стартовая площадка → рынок → эстакада → крыши → каньон → ангар. 8 ворот-чекпоинтов, 2 лазера, развилки с обходами. Правила и сеть — раздел «Карта Race» ниже |
|
||||||
|
|
||||||
**Правка и пересборка Parkour** (всё в `Blender models/parkour_02/`):
|
**Правка и пересборка 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 за полёт).
|
||||||
@@ -133,6 +136,41 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ (
|
|||||||
а в пресет «Web» — `maps/<имя>/*` в `exclude_filter`. Добавь вызов в `tools/export_all.sh`.
|
а в пресет «Web» — `maps/<имя>/*` в `exclude_filter`. Добавь вызов в `tools/export_all.sh`.
|
||||||
4. В `PORTALS` (`maps/hub/hub.gd`) допиши запись `{title, map, scene, pack, color}` — портал появится сам.
|
4. В `PORTALS` (`maps/hub/hub.gd`) допиши запись `{title, map, scene, pack, color}` — портал появится сам.
|
||||||
|
|
||||||
|
## Карта Race (гонка)
|
||||||
|
|
||||||
|
Файлы: `maps/race/` — `race.tscn`, `race.gd` (внешний вид, ворота, лазеры, срыв, дождь/гул, **музыка**), `race_rules.gd` (правила, HUD, сеть), `race_data.gd`
|
||||||
|
(генерируется из `Blender models/race_01/`, не править), `race.glb`, `sky.png`, `baked.gdshader`, `audio/music.ogg`.
|
||||||
|
Пакет `race.pck` (в нём `include_filter`: sky.png, baked.gdshader, race_data.gd, race_rules.gd, race.gd, audio/*; без него файлы, которые скрипт грузит по строке, не попадают в пакет).
|
||||||
|
|
||||||
|
**Правила.** ОЖИДАНИЕ → ОТСЧЁТ → ГОНКА → ИТОГИ → ОЖИДАНИЕ.
|
||||||
|
- Ожидание: бегаем свободно. **R** (действие `ready`) переключает «готов»; подсказка «R — готов (n/m)». Все готовы (хотя бы один) → отсчёт.
|
||||||
|
- Отсчёт 6 с (секунда «приготовьтесь», затем 5…1): все телепортируются в слоты (номер = индекс в отсортированном списке id комнаты; больше 8 — рядами позади, шаг 3 м) лицом по трассе и **заморожены** (`player.input_frozen`; обзор мышью работает).
|
||||||
|
- Гонка: ворота только по порядку (пересечение плоскости ворот по ходу трассы в пределах полуширины+1 м; чужие ворота игнорируются). Последние ворота = финиш: фиксируется время, дальше бежим свободно. Сплит: «ВОРОТА n время ±разница с личным лучшим забегом».
|
||||||
|
- Место: финишировавшие — по времени, остальные — по числу ворот, затем по расстоянию до следующих ворот. Конец круга: финишировали все присутствующие **или** прошло 45 с после первого финиша; игрок вышел — убирается из расчётов (если остался один — бежит до финиша). Итоги ~8 с, затем ожидание; готовность сброшена, игроки стоят где стояли.
|
||||||
|
- Зашедший в разгар круга бегает без зачёта, видит «ГОНКА ИДЁТ» (определяется по маякам других игроков) и участвует со следующего круга.
|
||||||
|
- Срыв: ниже **последних пройденных ворот** на 32 м (до первых ворот — ниже стартового слота) или лазер → «СОРВАЛСЯ!», возрождение на последних воротах лицом по трассе (`Basis(UP, yaw)`; **знак проверен**: `rotation.y = yaw_deg` из `race_data.gd` даёт нужное направление: yaw -90 = на +X). Личный рекорд и сплиты лучшего забега — `user://race_best.cfg` (ConfigFile).
|
||||||
|
- **Музыка** `audio/music.ogg` (Ogg Vorbis, моно 32 кГц, 0,6 МБ; `ffmpeg -i исходник.mp3 -vn -map_metadata -1 -ac 1 -ar 32000 -c:a libvorbis -q:a 0 music.ogg`) включается в момент «СТАРТ!» с начала, играет до возврата в ожидание и плавно (0,8 с) затихает; дождь и гул остаются под ней. Зашедший в разгар круга слышит её, пока идёт чужой круг.
|
||||||
|
|
||||||
|
**Кто что считает (сервер ничего не знает про гонку).** Каждый клиент ведёт свою копию состояния (`race_rules.gd`) и обменивается событиями `Net.RACE_*` через `submit_race(kind, a, b)` / `recv_race(id, kind, a, b)` (+ сигнал `Net.race_event`; сервер просто пересылает остальным в комнате):
|
||||||
|
|
||||||
|
| kind | a | b | Кто шлёт |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `RACE_READY` | 1/0 | — | игрок при нажатии R |
|
||||||
|
| `RACE_START` | — | unix-время старта (отправитель: `now + 6`) | только **наименьший id** в комнате, когда видит, что готовы все (поэтому двое «последних готовых» дают один старт; второй START от большего id игнорируется) |
|
||||||
|
| `RACE_CP` | индекс ворот | время гонки | игрок при прохождении ворот |
|
||||||
|
| `RACE_FINISH` | — | время финиша | игрок на последних воротах |
|
||||||
|
| `RACE_STATE` | биты: 0..7 пройдено ворот, 8 готов, 9..10 фаза | время финиша или -1 | «маяк» раз в секунду и при смене фазы; по нему догоняют потерявшие событие и зашедшие позже |
|
||||||
|
|
||||||
|
Время: на получении START остаток считается как `b - unix_now`; если он отличается от 6 с больше чем на 0,4 с (часы машин расходятся), считаем 5,9 с от получения. Время гонки у каждого = `(ticks - start_tick)`, финиш `b` пересылается точным числом, поэтому таблицы мест у всех совпадают. «Готов» учитывается только в ожидании; на START все флаги готовности сбрасываются. Ник и цвет в таблице — как у призрака (`Игрок id%1000`, `Color.from_hsv(id*0.618034…)`).
|
||||||
|
|
||||||
|
**Проверка по существу** (всё в `tools/test/`):
|
||||||
|
- `race_game_test.sh [папка]` — локальный сервер + 2 клиента-бота (A, B) на карте race два круга; третий (C) заходит в разгар круга 1, участвует во втором и выходит посреди него. Проверяет: одинаковые таблицы мест на всех, один START на круг, слоты, синхронность старта, заморозку на отсчёте, музыку после старта, возрождение лицом по трассе (смерть и срыв), зрителя. Сверяет `race_game_check.py`; боты — `race_bot.gd` (автозагрузка во временной копии проекта) по маршруту `race_route.json`. Порт 9080 должен быть свободен; рекорды ботов пишутся в папку теста (`--race-best=`), настоящий не трогается.
|
||||||
|
- `race_tour.tscn` — кадры для проверки HUD/ворот (`--mode=gates|hud`, см. заголовок).
|
||||||
|
- Пакет: `godot --headless --export-pack Race /tmp/x/race.pck`, затем `strings /tmp/x/race.pck | grep -c` для `sky.png`, `baked.gdshader`, `race_data`, `race_rules.gd`, `music.ogg`, `race.glb` (каждое > 0).
|
||||||
|
- Пакетная загрузка через портал хаба: `tools/test/race_check.txt` (по образцу `parkour_check.txt`).
|
||||||
|
|
||||||
|
**Выкладка:** старый клиент с новым сервером (и наоборот) **не работает вообще** — Godot сверяет контрольную сумму набора RPC узла `/root/Net` («rpc node checksum failed»), проверено; поэтому порядок строго по README («Деплой»). `net/net.gd` изменён (новые RPC `submit_race`/`recv_race`) → после пуша `grid-world` сразу `tools/deploy_server.sh root@192.168.10.110`.
|
||||||
|
|
||||||
## Преследователи
|
## Преследователи
|
||||||
|
|
||||||
`chasers/chaser.gd` — один скрипт для всех. Сцены (`rock.tscn`, `verity.tscn`) — это просто модель + звук + `NavigationAgent3D`.
|
`chasers/chaser.gd` — один скрипт для всех. Сцены (`rock.tscn`, `verity.tscn`) — это просто модель + звук + `NavigationAgent3D`.
|
||||||
@@ -310,6 +348,13 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
|
|||||||
- Не мерялось: нагрузка Parkour в браузере (~145 тыс. треугольников + дождь 2200 частиц, небо ~6 МБ в видеопамяти), звук (шум дождя, гул, песня) на слух.
|
- Не мерялось: нагрузка Parkour в браузере (~145 тыс. треугольников + дождь 2200 частиц, небо ~6 МБ в видеопамяти), звук (шум дождя, гул, песня) на слух.
|
||||||
- Расходный ресурс: `mcp__blender__look` очень дорог по токенам (сотни тысяч за сессию) — снимки просматривай из Godot (`parkour_tour`), в Blender смотри только геометрию.
|
- Расходный ресурс: `mcp__blender__look` очень дорог по токенам (сотни тысяч за сессию) — снимки просматривай из Godot (`parkour_tour`), в Blender смотри только геометрию.
|
||||||
|
|
||||||
|
**Race — что выяснено**
|
||||||
|
- Имя `ready` у переменной в `Node` занято сигналом движка (`Node.ready`): флаги готовности названы `flags_ready`.
|
||||||
|
- Данные ворот — центр капсулы на полу: ворота ставим на `pos.y - 1.2`; слоты на 1,25 м, при заморозке игрок оседает на ~9 см (это нормально).
|
||||||
|
- Маршрут бота `route.json` проходит ворота ровно по центру; точки маршрута на воротах бот обязан «брать» только после пересечения плоскости (иначе срезает угол, как делал мой первый бот).
|
||||||
|
- В `--write-movie` с `canvas_items`+`expand` видимая область шире окна, и подписи у левого края обрезаются; HUD слева отступает на 64 px.
|
||||||
|
- Кадры гонки: `race_tour.tscn --mode=hud` (R, отсчёт, пробег, итоги). Подробности — раздел «Карта Race».
|
||||||
|
|
||||||
## Дорожная карта
|
## Дорожная карта
|
||||||
|
|
||||||
### Цель: играть с телефона
|
### Цель: играть с телефона
|
||||||
|
|||||||
+51
-1
@@ -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/*, chasers/rock/*, chasers/verity/*"
|
exclude_filter="tools/*, maps/sandbox/*, maps/backrooms/*, maps/parkour/*, maps/race/*, 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
|
||||||
@@ -200,3 +200,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.4]
|
||||||
|
|
||||||
|
name="Race"
|
||||||
|
platform="Web"
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="resources"
|
||||||
|
export_files=PackedStringArray("res://maps/race/race.tscn")
|
||||||
|
include_filter="maps/race/sky.png, maps/race/baked.gdshader, maps/race/race_data.gd, maps/race/race_rules.gd, maps/race/race.gd, maps/race/audio/*"
|
||||||
|
exclude_filter="player/*, objects/*, chasers/chaser.gd"
|
||||||
|
export_path="../grid-world/race.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.4.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,6 +7,7 @@ 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)},
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROOM_WIDTH := 28.0
|
const ROOM_WIDTH := 28.0
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="oggvorbisstr"
|
||||||
|
type="AudioStreamOggVorbis"
|
||||||
|
uid="uid://bcwtlavc5apcb"
|
||||||
|
path="res://.godot/imported/music.ogg-478941bdb69023107f4668af82e2cd34.oggvorbisstr"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://maps/race/audio/music.ogg"
|
||||||
|
dest_files=["res://.godot/imported/music.ogg-478941bdb69023107f4668af82e2cd34.oggvorbisstr"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
loop=false
|
||||||
|
loop_offset=0
|
||||||
|
bpm=0
|
||||||
|
beat_count=0
|
||||||
|
bar_beats=4
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
// Поверхности уровня Parkour: освещение запечено в цвета вершин (Blender, Cycles), динамических источников нет.
|
||||||
|
// Итог = цвет материала × запечённый свет × мелкое «зерно» бетона (по мировым координатам) + слабый блик на скользящих углах.
|
||||||
|
render_mode unshaded;
|
||||||
|
|
||||||
|
uniform vec3 albedo_color = vec3(0.1);
|
||||||
|
uniform float light_scale = 8.0; // цвета вершин сжаты в 8 раз при экспорте (см. bake.py)
|
||||||
|
uniform float exposure = 1.0;
|
||||||
|
uniform sampler2D grain : repeat_enable, filter_linear_mipmap;
|
||||||
|
uniform float grain_scale = 0.3;
|
||||||
|
uniform float grain_amount = 0.45;
|
||||||
|
uniform sampler2D sky_tex : source_color, filter_linear_mipmap; // панорама неба: отражается в «мокрых» горизонтальных поверхностях
|
||||||
|
uniform float wet = 0.6;
|
||||||
|
|
||||||
|
varying vec3 wpos;
|
||||||
|
varying vec3 wnorm;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
wpos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||||
|
wnorm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec2 pano_uv(vec3 d) {
|
||||||
|
return vec2(atan(d.x, -d.z) / (2.0 * PI) + 0.5, acos(clamp(d.y, -1.0, 1.0)) / PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec3 n = abs(wnorm);
|
||||||
|
n /= (n.x + n.y + n.z + 0.0001);
|
||||||
|
float g = texture(grain, wpos.zy * grain_scale).r * n.x
|
||||||
|
+ texture(grain, wpos.xz * grain_scale).r * n.y
|
||||||
|
+ texture(grain, wpos.xy * grain_scale).r * n.z;
|
||||||
|
float gr = mix(1.0 - grain_amount * 0.5, 1.0 + grain_amount * 0.5, g);
|
||||||
|
vec3 lit = COLOR.rgb * light_scale;
|
||||||
|
vec3 nv = normalize(NORMAL);
|
||||||
|
float ndv = clamp(dot(nv, normalize(VIEW)), 0.0, 1.0);
|
||||||
|
float fres = 0.05 + 0.95 * pow(1.0 - ndv, 4.0);
|
||||||
|
vec3 rw = normalize((INV_VIEW_MATRIX * vec4(reflect(-normalize(VIEW), nv), 0.0)).xyz);
|
||||||
|
float up = smoothstep(0.6, 0.95, wnorm.y); // блестят только горизонтальные поверхности
|
||||||
|
vec3 refl = textureLod(sky_tex, pano_uv(rw), 3.0).rgb;
|
||||||
|
ALBEDO = albedo_color * lit * gr * exposure + refl * fres * wet * up * (0.5 + 0.5 * min(lit.r + lit.g + lit.b, 1.0));
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dn0bcs7hv627b
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
extends Node3D
|
||||||
|
# Карта «Гонка» (RACE): забег на ногах по мрачному городу — от стартовой площадки через рынок, эстакаду, крыши, каньон и ангар
|
||||||
|
# до финиша. Модель — race.glb, данные — race_data.gd (оба генерирует `Blender models/race_01/build_race.py` из layout.py).
|
||||||
|
# Здесь: внешний вид (материалы по именам из GLB, окружение, дождь, гул), ворота, лазеры, срыв, возрождение на последних воротах.
|
||||||
|
# Правила гонки (ожидание, отсчёт, место, итоги, сеть) — в race_rules.gd; сервер про них ничего не знает.
|
||||||
|
|
||||||
|
const Data = preload("res://maps/race/race_data.gd")
|
||||||
|
const Rules = preload("res://maps/race/race_rules.gd")
|
||||||
|
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 _lasers: Array = [] # {area, mat, mesh, mode, period, on, phase}
|
||||||
|
var _gates: Array = [] # {root, mat, sheet, label} — визуал ворот по индексам CHECKPOINTS
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
Net.enter_map("race")
|
||||||
|
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)
|
||||||
|
_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.11, 0.115, 0.125), "Dark": Color(0.016, 0.017, 0.02), "Steel": Color(0.13, 0.14, 0.15),
|
||||||
|
"PaintY": Color(0.38, 0.30, 0.06), "PaintR": Color(0.26, 0.08, 0.05), "PaintB": Color(0.05, 0.08, 0.11),
|
||||||
|
"PaintO": Color(0.33, 0.14, 0.05),
|
||||||
|
}
|
||||||
|
# Светящиеся (мрачная палитра): Cyan — холодный белёсый, Magenta — тревожный красный, Orange — натриевая лампа
|
||||||
|
const GLOWS := {
|
||||||
|
"NeonCyan": [Color(0.50, 0.86, 0.82), 0.65], "NeonMagenta": [Color(0.95, 0.07, 0.04), 1.2],
|
||||||
|
"NeonOrange": [Color(1.0, 0.42, 0.08), 1.3], "NeonYellow": [Color(0.92, 0.62, 0.10), 1.1],
|
||||||
|
"NeonGreen": [Color(0.45, 0.85, 0.35), 1.0], "NeonBlue": [Color(0.25, 0.40, 0.70), 1.0],
|
||||||
|
"NeonRed": [Color(1.0, 0.05, 0.05), 1.5], "NeonWhite": [Color(1.0, 0.72, 0.42), 0.9],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Коллизию создаёт импорт (узел Collision-colonly), здесь — внешний вид: материалы по именам из Blender
|
||||||
|
func _prepare_map() -> void:
|
||||||
|
var shader := load("res://maps/race/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/race/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/race/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.26, 0.29, 0.33)
|
||||||
|
env.ambient_light_energy = 0.6
|
||||||
|
env.fog_enabled = true
|
||||||
|
env.fog_light_color = Color(0.2, 0.16, 0.13) # грязный дымный воздух
|
||||||
|
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.8
|
||||||
|
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.6, 0.68, 0.75)
|
||||||
|
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.55, 0.30, 0.14) # ещё не очередные: тусклые
|
||||||
|
const GATE_NEXT := Color(1.0, 0.5, 0.15) # очередные: яркий натриевый
|
||||||
|
const GATE_DONE := Color(0.45, 0.85, 0.35) # пройдены: зелёные
|
||||||
|
|
||||||
|
# Угол рыскания из данных (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 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.016
|
||||||
|
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})
|
||||||
|
|
||||||
|
# Раскраска ворот по прогрессу игрока: пройденные зелёные, очередные яркие, остальные тусклые
|
||||||
|
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
|
||||||
|
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.04, 0.05), 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.06
|
||||||
|
l.mesh.visible = active or l.mode == 1
|
||||||
|
|
||||||
|
# ---------- выход ----------
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Ливень вокруг игрока: тонкие вертикальные штрихи, летящие по ветру; в ангаре (HALL_* из race_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.7, 0.78, 0.82, 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
|
||||||
|
_rain.emitting = not (p.x > Data.HALL_X0 - 2.0 and p.x < Data.HALL_X1 + 2.0 and p.z > Data.HALL_Z0 - 2.0 and p.z < Data.HALL_Z1 + 2.0 and p.y > Data.HALL_FLOOR - 2.0 and p.y < Data.HALL_CEIL + 1.0)
|
||||||
|
|
||||||
|
# Низкий промышленный гул и шум дождя: 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)
|
||||||
|
|
||||||
|
# Песня гонки: 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/race/audio/music.ogg") as AudioStreamOggVorbis
|
||||||
|
stream.loop = true
|
||||||
|
_music = AudioStreamPlayer.new()
|
||||||
|
_music.stream = stream
|
||||||
|
_music.volume_db = MUSIC_DB
|
||||||
|
add_child(_music)
|
||||||
|
if _music_tween:
|
||||||
|
_music_tween.kill()
|
||||||
|
if on:
|
||||||
|
if not _music.playing:
|
||||||
|
_music.volume_db = MUSIC_DB
|
||||||
|
_music.play() # каждый круг — с начала
|
||||||
|
elif _music.playing:
|
||||||
|
_music_tween = create_tween()
|
||||||
|
_music_tween.tween_property(_music, "volume_db", -60.0, 0.8)
|
||||||
|
_music_tween.tween_callback(_music.stop)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c18dfdsjr2uje
|
||||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://c6jjwrw7lfse7"
|
||||||
|
path="res://.godot/imported/race.glb-32a8595cf9a31ef83d00b04c51aac272.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://maps/race/race.glb"
|
||||||
|
dest_files=["res://.godot/imported/race.glb-32a8595cf9a31ef83d00b04c51aac272.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
mesh_library/use_node_names_as_mesh_names=false
|
||||||
|
array_mesh/deduplicate_surfaces=true
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
|
gltf/texture_map_mode=1
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://maps/race/race.gd" id="1_level"]
|
||||||
|
[ext_resource type="PackedScene" path="res://maps/race/race.glb" id="2_map"]
|
||||||
|
[ext_resource type="PackedScene" path="res://player/player.tscn" id="3_player"]
|
||||||
|
|
||||||
|
[node name="Race" type="Node3D"]
|
||||||
|
script = ExtResource("1_level")
|
||||||
|
|
||||||
|
[node name="Map" parent="." instance=ExtResource("2_map")]
|
||||||
|
|
||||||
|
[node name="Player" parent="." instance=ExtResource("3_player")]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Сгенерировано Blender models/race_01/build_race.py из layout.py — не править руками
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Старт: 8 слотов в ряд поперёк трассы, лицом по трассе (-Z); центр капсулы
|
||||||
|
const START_SLOTS := [
|
||||||
|
Vector3(-9.80, 1.25, -28.00),
|
||||||
|
Vector3(-7.00, 1.25, -28.00),
|
||||||
|
Vector3(-4.20, 1.25, -28.00),
|
||||||
|
Vector3(-1.40, 1.25, -28.00),
|
||||||
|
Vector3(1.40, 1.25, -28.00),
|
||||||
|
Vector3(4.20, 1.25, -28.00),
|
||||||
|
Vector3(7.00, 1.25, -28.00),
|
||||||
|
Vector3(9.80, 1.25, -28.00),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ворота по порядку: [центр капсулы на полу ворот, полуширина поперёк трассы (м), рыскание (град; 0 = -Z, -90 = на +X)]. Последний = ФИНИШ
|
||||||
|
# На развилках ворота стоят только в общих местах (после слияния веток), поэтому между ними бывает до ~170 м.
|
||||||
|
const CHECKPOINTS := [
|
||||||
|
[Vector3(0.00, 1.20, -78.00), 13.0, 0.0], # cp1
|
||||||
|
[Vector3(0.00, 5.70, -114.00), 8.0, 0.0], # cp2
|
||||||
|
[Vector3(66.00, 5.70, -195.00), 12.0, -90.0], # cp3
|
||||||
|
[Vector3(99.00, 6.20, -195.00), 13.0, -90.0], # cp4
|
||||||
|
[Vector3(150.00, 4.70, -195.00), 13.0, -90.0], # cp5
|
||||||
|
[Vector3(163.00, 0.40, -306.00), 10.0, 0.0], # cp6
|
||||||
|
[Vector3(162.00, -5.70, -362.00), 12.0, 0.0], # cp7
|
||||||
|
[Vector3(161.00, -5.70, -440.00), 28.0, 0.0], # cp8
|
||||||
|
]
|
||||||
|
|
||||||
|
# Лазеры: центр, размер, режим (0 — всегда, 1 — пульсирует), период, время включения, сдвиг
|
||||||
|
const LASERS := [
|
||||||
|
[Vector3(170.50, -6.45, -385.25), Vector3(5.00, 0.90, 0.50), 0, 0.00, 0.00, 0.00], # t_floor
|
||||||
|
[Vector3(170.50, -5.30, -397.25), Vector3(5.00, 0.60, 0.50), 1, 2.60, 1.30, 0.00], # t_mid
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ангар (для отключения дождя): x0, x1, y0(+Z), y1, пол, потолок; в координатах Godot по Z — расстояние по трассе со знаком минус
|
||||||
|
const HALL_X0 := 148.0
|
||||||
|
const HALL_X1 := 173.0
|
||||||
|
const HALL_Z0 := -416.0
|
||||||
|
const HALL_Z1 := -364.0
|
||||||
|
const HALL_FLOOR := -6.9
|
||||||
|
const HALL_CEIL := 2.1
|
||||||
|
|
||||||
|
# Портал в хаб за финишем: позиция на полу площадки
|
||||||
|
const PORTAL := Vector3(161.00, -6.90, -490.00)
|
||||||
|
# Длина безопасного маршрута, м
|
||||||
|
const TRACK_LENGTH := 632.9
|
||||||
|
# Срыв: на столько ниже последнего чекпоинта — возрождение
|
||||||
|
const KILL_DEPTH := 32.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dowlbtftiudoh
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
extends Node
|
||||||
|
# Правила режима «Гонка»: ожидание -> отсчёт -> гонка -> итоги -> снова ожидание, HUD и обмен событиями.
|
||||||
|
# Сервер про гонку ничего не знает, он только пересылает события (Net.RACE_*, submit_race/recv_race) внутри комнаты карты.
|
||||||
|
# Каждый клиент ведёт собственную копию состояния и приходит к одному результату, потому что:
|
||||||
|
# - старт даёт единственный отправитель (наименьший id в комнате) и все считают время от одного момента;
|
||||||
|
# - ворота засчитывает сам игрок и сообщает всем (CP/FINISH), порядок мест вычисляется одинаково у всех;
|
||||||
|
# - раз в секунду каждый шлёт «маяк» (RACE_STATE) — по нему догоняют потерявшие событие и пришедшие позже.
|
||||||
|
# Биты маяка (a): 0..7 — пройдено ворот; 8 — готов; 9..10 — фаза отправителя (Phase).
|
||||||
|
|
||||||
|
const Data = preload("res://maps/race/race_data.gd")
|
||||||
|
|
||||||
|
enum Phase { WAIT, COUNTDOWN, RACE, RESULTS }
|
||||||
|
|
||||||
|
const COUNTDOWN_TIME := 6.0 # от START до старта: секунда на «приготовьтесь» и 5…1
|
||||||
|
const CLOCK_TOLERANCE := 0.4 # на сколько unix-время старта может разойтись с ожидаемым, прежде чем считаем часы чужими
|
||||||
|
const MAX_SLOTS := 8
|
||||||
|
const EXTRA_ROW := 3.0 # запасные места: ряды позади стартовых слотов (на столько метров назад)
|
||||||
|
const AFTER_FIRST_FINISH := 45.0 # гонка кончается через столько секунд после первого финиша
|
||||||
|
const RESULTS_TIME := 8.0
|
||||||
|
const BEACON_PERIOD := 1.0
|
||||||
|
const GATE_HEIGHT := 5.0 # диапазон высот, в котором пересечение плоскости ворот засчитывается
|
||||||
|
const GATE_MARGIN := 1.0 # запас по ширине сверх полуширины ворот
|
||||||
|
const TELEPORT_JUMP := 4.0 # смещение за кадр больше этого — телепорт (возрождение), не пересечение ворот
|
||||||
|
|
||||||
|
var map: Node3D # race.gd: gate_pos/gate_yaw/gate_half/set_gate_progress
|
||||||
|
var player: Node3D
|
||||||
|
var phase := Phase.WAIT
|
||||||
|
var my_id := 1
|
||||||
|
var flags_ready := {} # id -> готов (только в ожидании)
|
||||||
|
var passed := {} # id -> пройдено ворот (участники круга)
|
||||||
|
var finish := {} # id -> время финиша, с
|
||||||
|
var peer_phase := {} # id -> фаза по последнему маяку
|
||||||
|
var known := {} # id -> true: этого игрока видели в комнате
|
||||||
|
var participants: Array = [] # отсортированные id участников текущего круга
|
||||||
|
var start_tick := 0 # Time.get_ticks_msec() момента старта
|
||||||
|
var start_sender := 0
|
||||||
|
var results: Array = [] # итоги круга: {id, place, time, gates}
|
||||||
|
var results_end_tick := 0
|
||||||
|
var splits: Array = [] # свои сплиты круга
|
||||||
|
var best_time := -1.0
|
||||||
|
var best_splits: Array = []
|
||||||
|
var debug := false # --race-log: факты в консоль (тесты)
|
||||||
|
|
||||||
|
var _beacon_timer := 0.0
|
||||||
|
var _prev_pos := Vector3.ZERO
|
||||||
|
var _have_prev := false
|
||||||
|
var _go_until := 0
|
||||||
|
var _split_until := 0
|
||||||
|
var _new_record := false
|
||||||
|
var _music_on := false
|
||||||
|
|
||||||
|
# HUD
|
||||||
|
var _timer_label: Label
|
||||||
|
var _best_label: Label
|
||||||
|
var _place_label: Label
|
||||||
|
var _gates_label: Label
|
||||||
|
var _center_label: Label
|
||||||
|
var _split_label: Label
|
||||||
|
var _hint_label: Label
|
||||||
|
var _results_panel: PanelContainer
|
||||||
|
|
||||||
|
func setup(m: Node3D) -> void:
|
||||||
|
map = m
|
||||||
|
player = m.player
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
debug = OS.get_cmdline_user_args().has("--race-log")
|
||||||
|
my_id = _current_id()
|
||||||
|
known[my_id] = true
|
||||||
|
_load_best()
|
||||||
|
_build_hud()
|
||||||
|
Net.race_event.connect(_on_race_event)
|
||||||
|
map.set_gate_progress(0)
|
||||||
|
_log("карта готова, id=%d, ворот=%d" % [my_id, Data.CHECKPOINTS.size()])
|
||||||
|
|
||||||
|
func _log(text: String) -> void:
|
||||||
|
if debug:
|
||||||
|
print("[RACE %d] %s" % [my_id, text])
|
||||||
|
|
||||||
|
# ---------- участники ----------
|
||||||
|
|
||||||
|
func _current_id() -> int:
|
||||||
|
return multiplayer.get_unique_id() if Net.connected else 1
|
||||||
|
|
||||||
|
# Все игроки комнаты, включая нас (чужие — по призракам Net.ghosts)
|
||||||
|
func _room_ids() -> Array:
|
||||||
|
var ids: Array = [my_id]
|
||||||
|
for id in Net.ghosts.keys():
|
||||||
|
if is_instance_valid(Net.ghosts[id]):
|
||||||
|
ids.append(id)
|
||||||
|
ids.sort()
|
||||||
|
return ids
|
||||||
|
|
||||||
|
func _refresh_room() -> void:
|
||||||
|
var now := _current_id()
|
||||||
|
if now != my_id: # подключились к серверу: у нас другой id — начинаем с чистого листа
|
||||||
|
my_id = now
|
||||||
|
_reset_all()
|
||||||
|
var ids := _room_ids()
|
||||||
|
for id in ids:
|
||||||
|
known[id] = true
|
||||||
|
for id in known.keys():
|
||||||
|
if not ids.has(id):
|
||||||
|
_forget(id)
|
||||||
|
|
||||||
|
func _reset_all() -> void:
|
||||||
|
flags_ready.clear()
|
||||||
|
passed.clear()
|
||||||
|
finish.clear()
|
||||||
|
peer_phase.clear()
|
||||||
|
known.clear()
|
||||||
|
known[my_id] = true
|
||||||
|
participants.clear()
|
||||||
|
_set_phase(Phase.WAIT)
|
||||||
|
player.input_frozen = false
|
||||||
|
map.set_gate_progress(0)
|
||||||
|
|
||||||
|
# Игрок вышел: убираем из расчётов
|
||||||
|
func _forget(id: int) -> void:
|
||||||
|
known.erase(id)
|
||||||
|
flags_ready.erase(id)
|
||||||
|
passed.erase(id)
|
||||||
|
finish.erase(id)
|
||||||
|
peer_phase.erase(id)
|
||||||
|
participants.erase(id)
|
||||||
|
_log("игрок %d вышел" % id)
|
||||||
|
|
||||||
|
func _name_of(id: int) -> String:
|
||||||
|
return "Игрок %d" % (id % 1000) # как у призрака (net/ghost.gd)
|
||||||
|
|
||||||
|
func _color_of(id: int) -> Color:
|
||||||
|
return Color.from_hsv(fmod(id * 0.618034, 1.0), 0.7, 1.0) # как у призрака
|
||||||
|
|
||||||
|
func _pos_of(id: int) -> Vector3:
|
||||||
|
if id == my_id:
|
||||||
|
return player.global_position
|
||||||
|
var g = Net.ghosts.get(id)
|
||||||
|
if g != null and is_instance_valid(g):
|
||||||
|
return g.position
|
||||||
|
return Vector3(1e6, 1e6, 1e6)
|
||||||
|
|
||||||
|
# ---------- сеть ----------
|
||||||
|
|
||||||
|
func _send(kind: int, a: int, b: float) -> void:
|
||||||
|
Net.send_race(kind, a, b)
|
||||||
|
|
||||||
|
func _beacon_bits() -> int:
|
||||||
|
var bits := mini(passed.get(my_id, 0), 255)
|
||||||
|
if flags_ready.get(my_id, false):
|
||||||
|
bits |= 1 << 8
|
||||||
|
bits |= int(phase) << 9
|
||||||
|
return bits
|
||||||
|
|
||||||
|
func _send_beacon() -> void:
|
||||||
|
_beacon_timer = 0.0
|
||||||
|
_send(Net.RACE_STATE, _beacon_bits(), finish.get(my_id, -1.0))
|
||||||
|
|
||||||
|
func _set_phase(p: Phase) -> void:
|
||||||
|
if p != phase:
|
||||||
|
phase = p
|
||||||
|
_log("фаза -> %s" % Phase.keys()[p])
|
||||||
|
_send_beacon()
|
||||||
|
|
||||||
|
func _on_race_event(id: int, kind: int, a: int, b: float) -> void:
|
||||||
|
if id == my_id:
|
||||||
|
return
|
||||||
|
match kind:
|
||||||
|
Net.RACE_READY:
|
||||||
|
if phase == Phase.WAIT:
|
||||||
|
flags_ready[id] = a != 0
|
||||||
|
Net.RACE_START:
|
||||||
|
_on_start(id, b)
|
||||||
|
Net.RACE_CP:
|
||||||
|
if participants.has(id):
|
||||||
|
passed[id] = maxi(passed.get(id, 0), a + 1)
|
||||||
|
Net.RACE_FINISH:
|
||||||
|
if participants.has(id):
|
||||||
|
finish[id] = b
|
||||||
|
passed[id] = Data.CHECKPOINTS.size()
|
||||||
|
Net.RACE_STATE:
|
||||||
|
_on_beacon(id, a, b)
|
||||||
|
|
||||||
|
func _on_beacon(id: int, a: int, b: float) -> void:
|
||||||
|
var ph := (a >> 9) & 3
|
||||||
|
peer_phase[id] = ph
|
||||||
|
if phase == Phase.WAIT and ph == Phase.WAIT:
|
||||||
|
flags_ready[id] = ((a >> 8) & 1) == 1
|
||||||
|
if participants.has(id) and (phase == Phase.RACE or phase == Phase.RESULTS) and (ph == Phase.RACE or ph == Phase.RESULTS):
|
||||||
|
passed[id] = maxi(passed.get(id, 0), a & 255)
|
||||||
|
if b >= 0.0 and not finish.has(id):
|
||||||
|
finish[id] = b
|
||||||
|
|
||||||
|
# Идёт чужой круг, в который мы не попали (зашли позже): бегаем свободно и ждём следующего
|
||||||
|
func waiting_for_race() -> bool:
|
||||||
|
if phase != Phase.WAIT:
|
||||||
|
return false
|
||||||
|
for id in peer_phase:
|
||||||
|
if known.has(id) and (peer_phase[id] == Phase.COUNTDOWN or peer_phase[id] == Phase.RACE):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
# ---------- ожидание: готовность ----------
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("ready") and not event.is_echo():
|
||||||
|
toggle_ready()
|
||||||
|
|
||||||
|
func toggle_ready() -> void:
|
||||||
|
if phase != Phase.WAIT or waiting_for_race():
|
||||||
|
return
|
||||||
|
flags_ready[my_id] = not flags_ready.get(my_id, false)
|
||||||
|
_log("готов=%s" % flags_ready[my_id])
|
||||||
|
_send(Net.RACE_READY, 1 if flags_ready[my_id] else 0, 0.0)
|
||||||
|
|
||||||
|
# Все ли в комнате готовы; старт даёт наименьший id (если двое стали «последними готовыми», старт всё равно один)
|
||||||
|
func _check_all_ready() -> void:
|
||||||
|
if phase != Phase.WAIT or waiting_for_race() or not flags_ready.get(my_id, false):
|
||||||
|
return
|
||||||
|
var ids := _room_ids()
|
||||||
|
if ids[0] != my_id:
|
||||||
|
return
|
||||||
|
for id in ids:
|
||||||
|
if not flags_ready.get(id, false):
|
||||||
|
return
|
||||||
|
var t := Time.get_unix_time_from_system() + COUNTDOWN_TIME
|
||||||
|
_log("все готовы (%d), даю старт" % ids.size())
|
||||||
|
_send(Net.RACE_START, 0, t)
|
||||||
|
_on_start(my_id, t)
|
||||||
|
|
||||||
|
# ---------- отсчёт и старт ----------
|
||||||
|
|
||||||
|
func _slot_pos(i: int) -> Vector3:
|
||||||
|
var base: Vector3 = Data.START_SLOTS[i % MAX_SLOTS]
|
||||||
|
return base + Vector3(0, 0, EXTRA_ROW * (i / MAX_SLOTS)) # лишние — рядами позади
|
||||||
|
|
||||||
|
func _on_start(sender: int, unix_start: float) -> void:
|
||||||
|
if phase == Phase.RACE or (phase == Phase.COUNTDOWN and sender >= start_sender):
|
||||||
|
return # второй START (двое решили одновременно): остаётся от наименьшего id
|
||||||
|
start_sender = sender
|
||||||
|
var remaining := COUNTDOWN_TIME
|
||||||
|
if sender != my_id:
|
||||||
|
remaining = unix_start - Time.get_unix_time_from_system()
|
||||||
|
if absf(remaining - COUNTDOWN_TIME) > CLOCK_TOLERANCE: # часы отправителя расходятся с нашими: считаем от получения
|
||||||
|
_log("часы расходятся на %.2f с, считаю от получения" % (remaining - COUNTDOWN_TIME))
|
||||||
|
remaining = COUNTDOWN_TIME - 0.1
|
||||||
|
start_tick = Time.get_ticks_msec() + int(remaining * 1000.0)
|
||||||
|
flags_ready.clear()
|
||||||
|
participants = _room_ids()
|
||||||
|
passed.clear()
|
||||||
|
finish.clear()
|
||||||
|
splits.clear()
|
||||||
|
_new_record = false
|
||||||
|
for id in participants:
|
||||||
|
passed[id] = 0
|
||||||
|
var slot := participants.find(my_id)
|
||||||
|
player.place(Transform3D(Basis.IDENTITY, _slot_pos(slot)))
|
||||||
|
player.input_frozen = true
|
||||||
|
_have_prev = false
|
||||||
|
map.set_gate_progress(0)
|
||||||
|
_set_phase(Phase.COUNTDOWN)
|
||||||
|
_log("старт от %d: слот %d из %d, через %.2f с, участники %s" % [sender, slot, participants.size(), remaining, participants])
|
||||||
|
|
||||||
|
func _begin_race() -> void:
|
||||||
|
player.input_frozen = false
|
||||||
|
_go_until = Time.get_ticks_msec() + 1200
|
||||||
|
_set_phase(Phase.RACE)
|
||||||
|
|
||||||
|
func race_time() -> float:
|
||||||
|
return (Time.get_ticks_msec() - start_tick) / 1000.0
|
||||||
|
|
||||||
|
# ---------- гонка ----------
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if phase != Phase.RACE or not participants.has(my_id) or finish.has(my_id) or player.is_dead:
|
||||||
|
_have_prev = false
|
||||||
|
return
|
||||||
|
var cur: Vector3 = player.global_position
|
||||||
|
if _have_prev:
|
||||||
|
var idx: int = passed.get(my_id, 0)
|
||||||
|
if (cur - _prev_pos).length() < TELEPORT_JUMP and _crossed(idx, _prev_pos, cur):
|
||||||
|
_pass_gate(idx)
|
||||||
|
_prev_pos = cur
|
||||||
|
_have_prev = true
|
||||||
|
|
||||||
|
# Отрезок prev->cur пересёк плоскость ворот i по ходу трассы в пределах ворот?
|
||||||
|
func _crossed(i: int, prev: Vector3, cur: Vector3) -> bool:
|
||||||
|
var p: Vector3 = map.gate_pos(i)
|
||||||
|
var yaw: float = map.gate_yaw(i)
|
||||||
|
var fwd := Vector3(-sin(yaw), 0, -cos(yaw)) # rotation.y = yaw поворачивает -Z именно сюда
|
||||||
|
var side := Vector3(cos(yaw), 0, -sin(yaw))
|
||||||
|
var d0 := (prev - p).dot(fwd)
|
||||||
|
var d1 := (cur - p).dot(fwd)
|
||||||
|
if not (d0 < 0.0 and d1 >= 0.0):
|
||||||
|
return false
|
||||||
|
var q := prev.lerp(cur, d0 / (d0 - d1))
|
||||||
|
if absf((q - p).dot(side)) > float(map.gate_half(i)) + GATE_MARGIN:
|
||||||
|
return false
|
||||||
|
var dy := q.y - p.y
|
||||||
|
return dy > -3.0 and dy < GATE_HEIGHT + 4.0
|
||||||
|
|
||||||
|
func _pass_gate(idx: int) -> void:
|
||||||
|
var t := race_time()
|
||||||
|
var n: int = Data.CHECKPOINTS.size()
|
||||||
|
passed[my_id] = idx + 1
|
||||||
|
splits.append(t)
|
||||||
|
map.set_gate_progress(idx + 1)
|
||||||
|
# возрождаемся на последних пройденных воротах, лицом по трассе
|
||||||
|
player.spawn_transform = Transform3D(Basis(Vector3.UP, map.gate_yaw(idx)), map.gate_pos(idx))
|
||||||
|
var diff := ""
|
||||||
|
if best_splits.size() > idx:
|
||||||
|
var d: float = t - best_splits[idx]
|
||||||
|
diff = " %s%.2f" % ["+" if d >= 0.0 else "-", absf(d)]
|
||||||
|
_split_label.add_theme_color_override("font_color", Color(0.9, 0.45, 0.35) if d >= 0.0 else Color(0.5, 0.85, 0.4))
|
||||||
|
else:
|
||||||
|
_split_label.add_theme_color_override("font_color", Color(0.92, 0.88, 0.8))
|
||||||
|
if idx == n - 1:
|
||||||
|
finish[my_id] = t
|
||||||
|
_send(Net.RACE_FINISH, 0, t)
|
||||||
|
_new_record = best_time < 0.0 or t < best_time
|
||||||
|
if _new_record:
|
||||||
|
best_time = t
|
||||||
|
best_splits = splits.duplicate()
|
||||||
|
_save_best()
|
||||||
|
_split_label.text = "ФИНИШ %s%s%s" % [_fmt(t), diff, " НОВЫЙ РЕКОРД" if _new_record else ""]
|
||||||
|
_log("ФИНИШ %.3f (место %d)" % [t, _my_place()])
|
||||||
|
else:
|
||||||
|
_split_label.text = "ВОРОТА %d %s%s" % [idx + 1, _fmt(t), diff]
|
||||||
|
_send(Net.RACE_CP, idx, t)
|
||||||
|
_log("ворота %d: %.3f" % [idx + 1, t])
|
||||||
|
_split_until = Time.get_ticks_msec() + 3500
|
||||||
|
_send_beacon()
|
||||||
|
|
||||||
|
# ---------- места ----------
|
||||||
|
|
||||||
|
func _dist_next(id: int) -> float:
|
||||||
|
var g: int = passed.get(id, 0)
|
||||||
|
if g >= Data.CHECKPOINTS.size():
|
||||||
|
return 0.0
|
||||||
|
return _pos_of(id).distance_to(map.gate_pos(g))
|
||||||
|
|
||||||
|
# Финишировавшие по времени, затем остальные: больше ворот, ближе к следующим
|
||||||
|
func ranking() -> Array:
|
||||||
|
var ids: Array = participants.duplicate()
|
||||||
|
ids.sort_custom(func(a: int, b: int) -> bool:
|
||||||
|
var fa := finish.has(a)
|
||||||
|
var fb := finish.has(b)
|
||||||
|
if fa and fb:
|
||||||
|
return finish[a] < finish[b] or (finish[a] == finish[b] and a < b)
|
||||||
|
if fa != fb:
|
||||||
|
return fa
|
||||||
|
var pa: int = passed.get(a, 0)
|
||||||
|
var pb: int = passed.get(b, 0)
|
||||||
|
if pa != pb:
|
||||||
|
return pa > pb
|
||||||
|
var da := _dist_next(a)
|
||||||
|
var db := _dist_next(b)
|
||||||
|
if not is_equal_approx(da, db):
|
||||||
|
return da < db
|
||||||
|
return a < b)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
func _my_place() -> int:
|
||||||
|
return ranking().find(my_id) + 1
|
||||||
|
|
||||||
|
func _check_race_end() -> void:
|
||||||
|
if participants.is_empty():
|
||||||
|
return
|
||||||
|
var first := -1.0 # время первого финиша
|
||||||
|
var all_done := true
|
||||||
|
for id in participants:
|
||||||
|
if finish.has(id):
|
||||||
|
if first < 0.0 or finish[id] < first:
|
||||||
|
first = finish[id]
|
||||||
|
else:
|
||||||
|
all_done = false
|
||||||
|
if all_done or (first >= 0.0 and race_time() >= first + AFTER_FIRST_FINISH):
|
||||||
|
_end_race()
|
||||||
|
|
||||||
|
func _end_race() -> void:
|
||||||
|
results.clear()
|
||||||
|
var rank := ranking()
|
||||||
|
for i in rank.size():
|
||||||
|
var id: int = rank[i]
|
||||||
|
results.append({"id": id, "place": i + 1, "time": finish.get(id, -1.0), "gates": passed.get(id, 0)})
|
||||||
|
results_end_tick = Time.get_ticks_msec() + int(RESULTS_TIME * 1000.0)
|
||||||
|
_set_phase(Phase.RESULTS)
|
||||||
|
_show_results()
|
||||||
|
for r in results:
|
||||||
|
_log("итог: место %d игрок %d время %.3f ворот %d" % [r.place, r.id, r.time, r.gates])
|
||||||
|
|
||||||
|
func _back_to_wait() -> void:
|
||||||
|
participants.clear()
|
||||||
|
passed.clear()
|
||||||
|
finish.clear()
|
||||||
|
flags_ready.clear()
|
||||||
|
_results_panel.visible = false
|
||||||
|
player.input_frozen = false
|
||||||
|
player.spawn_transform = Transform3D(Basis.IDENTITY, Data.START_SLOTS[0])
|
||||||
|
map.set_gate_progress(0)
|
||||||
|
_have_prev = false
|
||||||
|
_set_phase(Phase.WAIT)
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
_refresh_room()
|
||||||
|
_beacon_timer += delta
|
||||||
|
if _beacon_timer >= BEACON_PERIOD:
|
||||||
|
_send_beacon()
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
match phase:
|
||||||
|
Phase.WAIT:
|
||||||
|
_check_all_ready()
|
||||||
|
Phase.COUNTDOWN:
|
||||||
|
if now >= start_tick:
|
||||||
|
_begin_race()
|
||||||
|
Phase.RACE:
|
||||||
|
_check_race_end()
|
||||||
|
Phase.RESULTS:
|
||||||
|
if now >= results_end_tick:
|
||||||
|
_back_to_wait()
|
||||||
|
_update_hud()
|
||||||
|
# музыка: от «СТАРТ!» до возврата в ожидание; зашедший в разгар чужого круга тоже слышит её
|
||||||
|
var want := phase == Phase.RACE or phase == Phase.RESULTS
|
||||||
|
if phase == Phase.WAIT:
|
||||||
|
for id in peer_phase:
|
||||||
|
if known.has(id) and peer_phase[id] == Phase.RACE:
|
||||||
|
want = true
|
||||||
|
if want != _music_on:
|
||||||
|
_music_on = want
|
||||||
|
map.set_music(want)
|
||||||
|
|
||||||
|
# ---------- личный рекорд ----------
|
||||||
|
|
||||||
|
func _best_path() -> String:
|
||||||
|
for arg in OS.get_cmdline_user_args():
|
||||||
|
if arg.begins_with("--race-best="): # для тестов: не трогаем настоящий рекорд
|
||||||
|
return arg.substr("--race-best=".length())
|
||||||
|
return "user://race_best.cfg"
|
||||||
|
|
||||||
|
func _load_best() -> void:
|
||||||
|
var cf := ConfigFile.new()
|
||||||
|
if cf.load(_best_path()) == OK:
|
||||||
|
best_time = cf.get_value("race", "best", -1.0)
|
||||||
|
best_splits = cf.get_value("race", "splits", [])
|
||||||
|
|
||||||
|
func _save_best() -> void:
|
||||||
|
var cf := ConfigFile.new()
|
||||||
|
cf.set_value("race", "best", best_time)
|
||||||
|
cf.set_value("race", "splits", best_splits)
|
||||||
|
cf.save(_best_path())
|
||||||
|
|
||||||
|
# ---------- HUD ----------
|
||||||
|
|
||||||
|
const TEXT := Color(0.92, 0.88, 0.8)
|
||||||
|
const DIM := Color(0.66, 0.64, 0.6)
|
||||||
|
|
||||||
|
func _fmt(t: float) -> String:
|
||||||
|
t = maxf(t, 0.0)
|
||||||
|
return "%d:%05.2f" % [int(t / 60.0), fmod(t, 60.0)]
|
||||||
|
|
||||||
|
func _label(size: int, color: Color, parent: Control) -> Label:
|
||||||
|
var l := Label.new()
|
||||||
|
l.add_theme_font_size_override("font_size", size)
|
||||||
|
l.add_theme_color_override("font_color", color)
|
||||||
|
l.add_theme_constant_override("outline_size", maxi(size / 5, 4))
|
||||||
|
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||||
|
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
parent.add_child(l)
|
||||||
|
return l
|
||||||
|
|
||||||
|
func _build_hud() -> void:
|
||||||
|
var layer := CanvasLayer.new()
|
||||||
|
layer.layer = 12
|
||||||
|
add_child(layer)
|
||||||
|
var root := Control.new()
|
||||||
|
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
layer.add_child(root)
|
||||||
|
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
|
||||||
|
_timer_label = _label(40, TEXT, root)
|
||||||
|
_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_best_label = _label(20, DIM, root)
|
||||||
|
_best_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_place_label = _label(30, TEXT, root)
|
||||||
|
_gates_label = _label(22, DIM, root)
|
||||||
|
_center_label = _label(160, TEXT, root)
|
||||||
|
_center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_split_label = _label(28, TEXT, root)
|
||||||
|
_split_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_hint_label = _label(24, TEXT, root)
|
||||||
|
_hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
|
||||||
|
_timer_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP, Control.PRESET_MODE_MINSIZE, 0)
|
||||||
|
for l in [_timer_label, _best_label, _split_label, _hint_label]:
|
||||||
|
l.anchor_left = 0.0
|
||||||
|
l.anchor_right = 1.0
|
||||||
|
l.offset_left = 0.0
|
||||||
|
l.offset_right = 0.0
|
||||||
|
_timer_label.anchor_top = 0.0
|
||||||
|
_timer_label.offset_top = 14.0
|
||||||
|
_best_label.anchor_top = 0.0
|
||||||
|
_best_label.offset_top = 64.0
|
||||||
|
_split_label.anchor_top = 0.0
|
||||||
|
_split_label.offset_top = 100.0
|
||||||
|
_hint_label.anchor_top = 1.0
|
||||||
|
_hint_label.anchor_bottom = 1.0
|
||||||
|
_hint_label.offset_top = -80.0
|
||||||
|
_hint_label.offset_bottom = -40.0
|
||||||
|
_place_label.position = Vector2(64, 18)
|
||||||
|
_gates_label.position = Vector2(66, 58)
|
||||||
|
_center_label.anchor_left = 0.0
|
||||||
|
_center_label.anchor_right = 1.0
|
||||||
|
_center_label.anchor_top = 0.2
|
||||||
|
_center_label.anchor_bottom = 0.5
|
||||||
|
_center_label.offset_left = 0.0
|
||||||
|
_center_label.offset_right = 0.0
|
||||||
|
|
||||||
|
_results_panel = PanelContainer.new()
|
||||||
|
var sb := StyleBoxFlat.new()
|
||||||
|
sb.bg_color = Color(0.04, 0.045, 0.05, 0.84)
|
||||||
|
sb.border_color = Color(0.45, 0.30, 0.16, 0.9)
|
||||||
|
sb.set_border_width_all(2)
|
||||||
|
sb.set_content_margin_all(26)
|
||||||
|
_results_panel.add_theme_stylebox_override("panel", sb)
|
||||||
|
_results_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_results_panel.visible = false
|
||||||
|
root.add_child(_results_panel)
|
||||||
|
_results_panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||||||
|
_results_panel.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||||
|
_results_panel.grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||||
|
|
||||||
|
func _show_results() -> void:
|
||||||
|
for c in _results_panel.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
var box := VBoxContainer.new()
|
||||||
|
box.add_theme_constant_override("separation", 10)
|
||||||
|
_results_panel.add_child(box)
|
||||||
|
var title := _label(36, TEXT, box)
|
||||||
|
title.text = "ИТОГИ"
|
||||||
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
var grid := GridContainer.new()
|
||||||
|
grid.columns = 3
|
||||||
|
grid.add_theme_constant_override("h_separation", 34)
|
||||||
|
grid.add_theme_constant_override("v_separation", 6)
|
||||||
|
box.add_child(grid)
|
||||||
|
for r in results:
|
||||||
|
var c := _color_of(r.id)
|
||||||
|
var mine: bool = r.id == my_id
|
||||||
|
var place := _label(26, c if mine else TEXT, grid)
|
||||||
|
place.text = "%d" % r.place
|
||||||
|
var nm := _label(26, c, grid)
|
||||||
|
nm.text = _name_of(r.id) + (" (вы)" if mine else "")
|
||||||
|
var tm := _label(26, TEXT if r.time >= 0.0 else DIM, grid)
|
||||||
|
tm.text = _fmt(r.time) if r.time >= 0.0 else "не финишировал (%d/%d)" % [r.gates, Data.CHECKPOINTS.size()]
|
||||||
|
if best_time >= 0.0:
|
||||||
|
var rec := _label(20, DIM, box)
|
||||||
|
rec.text = "Личный рекорд: %s%s" % [_fmt(best_time), " (новый!)" if _new_record else ""]
|
||||||
|
rec.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_results_panel.visible = true
|
||||||
|
|
||||||
|
func _update_hud() -> void:
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
var racing := participants.has(my_id) and (phase == Phase.RACE or phase == Phase.RESULTS)
|
||||||
|
_timer_label.visible = racing
|
||||||
|
_place_label.visible = racing
|
||||||
|
_gates_label.visible = racing
|
||||||
|
_best_label.visible = racing or phase == Phase.COUNTDOWN
|
||||||
|
_best_label.text = "РЕКОРД %s" % (_fmt(best_time) if best_time >= 0.0 else "—")
|
||||||
|
_split_label.visible = now < _split_until and racing
|
||||||
|
if racing:
|
||||||
|
var n: int = Data.CHECKPOINTS.size()
|
||||||
|
if finish.has(my_id):
|
||||||
|
_timer_label.text = _fmt(finish[my_id])
|
||||||
|
_timer_label.add_theme_color_override("font_color", Color(0.5, 0.85, 0.4))
|
||||||
|
else:
|
||||||
|
_timer_label.text = _fmt(race_time())
|
||||||
|
_timer_label.add_theme_color_override("font_color", TEXT)
|
||||||
|
_place_label.text = "МЕСТО %d/%d" % [_my_place(), participants.size()]
|
||||||
|
_gates_label.text = "ВОРОТА %d/%d" % [passed.get(my_id, 0), n]
|
||||||
|
|
||||||
|
# центр: отсчёт, «СТАРТ!»
|
||||||
|
_center_label.visible = false
|
||||||
|
if phase == Phase.COUNTDOWN:
|
||||||
|
var rem := (start_tick - now) / 1000.0
|
||||||
|
_center_label.visible = true
|
||||||
|
if rem > COUNTDOWN_TIME - 1.0:
|
||||||
|
_center_label.add_theme_font_size_override("font_size", 56)
|
||||||
|
_center_label.text = "ПРИГОТОВЬТЕСЬ"
|
||||||
|
else:
|
||||||
|
_center_label.add_theme_font_size_override("font_size", 170)
|
||||||
|
_center_label.text = "%d" % maxi(ceili(rem), 1)
|
||||||
|
elif phase == Phase.RACE and now < _go_until:
|
||||||
|
_center_label.visible = true
|
||||||
|
_center_label.add_theme_font_size_override("font_size", 110)
|
||||||
|
_center_label.text = "СТАРТ!"
|
||||||
|
|
||||||
|
# подсказка внизу
|
||||||
|
_hint_label.visible = true
|
||||||
|
if phase == Phase.WAIT:
|
||||||
|
if waiting_for_race():
|
||||||
|
_hint_label.text = "ГОНКА ИДЁТ — дождитесь следующего круга"
|
||||||
|
else:
|
||||||
|
var ids := _room_ids()
|
||||||
|
var n_ready := 0
|
||||||
|
for id in ids:
|
||||||
|
if flags_ready.get(id, false):
|
||||||
|
n_ready += 1
|
||||||
|
if flags_ready.get(my_id, false):
|
||||||
|
_hint_label.text = "ГОТОВ (%d/%d) · R — отменить" % [n_ready, ids.size()]
|
||||||
|
else:
|
||||||
|
_hint_label.text = "R — готов (%d/%d)" % [n_ready, ids.size()]
|
||||||
|
elif phase == Phase.RACE and not participants.has(my_id):
|
||||||
|
_hint_label.text = "ГОНКА ИДЁТ"
|
||||||
|
else:
|
||||||
|
_hint_label.visible = false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b67m05gbpouwi
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
@@ -0,0 +1,41 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dihjhoubptq2c"
|
||||||
|
path.s3tc="res://.godot/imported/sky.png-7a54d5019f47e83829b0ee441cdb6eec.s3tc.ctex"
|
||||||
|
metadata={
|
||||||
|
"imported_formats": ["s3tc_bptc"],
|
||||||
|
"vram_texture": true
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://maps/race/sky.png"
|
||||||
|
dest_files=["res://.godot/imported/sky.png-7a54d5019f47e83829b0ee441cdb6eec.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
|
||||||
+27
@@ -18,7 +18,15 @@ const FLAG_MANTLE = 8
|
|||||||
const FLAG_WALL_RIGHT = 16 # Бег по стене: стена справа (иначе слева)
|
const FLAG_WALL_RIGHT = 16 # Бег по стене: стена справа (иначе слева)
|
||||||
const FLAG_AIR = 32 # В воздухе (не на полу, не на стене, не на уступе)
|
const FLAG_AIR = 32 # В воздухе (не на полу, не на стене, не на уступе)
|
||||||
|
|
||||||
|
# Виды событий гонки (см. maps/race/race_rules.gd): сервер их не разбирает, только пересылает
|
||||||
|
const RACE_READY = 0 # a: 1 — готов, 0 — нет
|
||||||
|
const RACE_START = 1 # b: unix-время старта (секунды, часы отправителя)
|
||||||
|
const RACE_CP = 2 # a: индекс пройденных ворот, b: время гонки, с
|
||||||
|
const RACE_FINISH = 3 # b: время финиша, с
|
||||||
|
const RACE_STATE = 4 # «маяк» раз в секунду: a — биты состояния (см. race_rules.gd), b — время финиша или -1
|
||||||
|
|
||||||
signal peer_respawned(pos: Vector3) # Чужой игрок возродился в точке pos
|
signal peer_respawned(pos: Vector3) # Чужой игрок возродился в точке pos
|
||||||
|
signal race_event(id: int, kind: int, a: int, b: float) # Событие гонки от игрока id (кроме нас самих)
|
||||||
|
|
||||||
var is_server := false
|
var is_server := false
|
||||||
var ghosts := {} # peer_id -> Ghost
|
var ghosts := {} # peer_id -> Ghost
|
||||||
@@ -165,6 +173,15 @@ func submit_life(dead: bool, pos: Vector3) -> void:
|
|||||||
for other in _room(peer_maps.get(sender, ""), sender):
|
for other in _room(peer_maps.get(sender, ""), sender):
|
||||||
recv_life.rpc_id(other, sender, dead, pos)
|
recv_life.rpc_id(other, sender, dead, pos)
|
||||||
|
|
||||||
|
# Клиент -> сервер: событие гонки (RACE_*). Сервер пересылает остальным на той же карте.
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func submit_race(kind: int, a: int, b: float) -> void:
|
||||||
|
if not is_server:
|
||||||
|
return
|
||||||
|
var sender := multiplayer.get_remote_sender_id()
|
||||||
|
for other in _room(peer_maps.get(sender, ""), sender):
|
||||||
|
recv_race.rpc_id(other, sender, kind, a, b)
|
||||||
|
|
||||||
# ---------- Клиент ----------
|
# ---------- Клиент ----------
|
||||||
|
|
||||||
func _server_url() -> String:
|
func _server_url() -> String:
|
||||||
@@ -274,6 +291,16 @@ func recv_state(id: int, pos: Vector3, yaw: float, pitch: float, flags: int) ->
|
|||||||
get_tree().current_scene.add_child(ghost)
|
get_tree().current_scene.add_child(ghost)
|
||||||
ghost.set_target(pos, yaw, pitch, flags)
|
ghost.set_target(pos, yaw, pitch, flags)
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func recv_race(id: int, kind: int, a: int, b: float) -> void:
|
||||||
|
if map_ready:
|
||||||
|
race_event.emit(id, kind, a, b)
|
||||||
|
|
||||||
|
# Отправить своё событие гонки остальным в комнате (без сети — молча ничего)
|
||||||
|
func send_race(kind: int, a: int, b: float) -> void:
|
||||||
|
if connected and map_ready:
|
||||||
|
submit_race.rpc_id(1, kind, a, b)
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
@rpc("authority", "reliable")
|
||||||
func recv_left(id: int) -> void:
|
func recv_left(id: int) -> void:
|
||||||
var ghost = ghosts.get(id)
|
var ghost = ghosts.get(id)
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ var spawn_transform: Transform3D
|
|||||||
var death_overlay: ColorRect = null
|
var death_overlay: ColorRect = null
|
||||||
var death_label: Label = null
|
var death_label: Label = null
|
||||||
|
|
||||||
|
# Заморозка ввода (режим «Гонка»: отсчёт до старта). Обзор мышью работает, движение и способности — нет.
|
||||||
|
var input_frozen: bool = false
|
||||||
|
|
||||||
# Коллайдер (опционально, ищется по имени CollisionShape3D)
|
# Коллайдер (опционально, ищется по имени CollisionShape3D)
|
||||||
var collision_shape: CollisionShape3D = null
|
var collision_shape: CollisionShape3D = null
|
||||||
var capsule: CapsuleShape3D = null
|
var capsule: CapsuleShape3D = null
|
||||||
@@ -247,6 +250,9 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
head.rotate_x(-event.relative.y * 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))
|
head.rotation.x = clamp(head.rotation.x, deg_to_rad(-80), deg_to_rad(80))
|
||||||
|
|
||||||
|
if input_frozen:
|
||||||
|
return
|
||||||
|
|
||||||
if event.is_action_pressed("crouch"):
|
if event.is_action_pressed("crouch"):
|
||||||
_try_start_slide() # Подкат: бег (sprint) + crouch
|
_try_start_slide() # Подкат: бег (sprint) + crouch
|
||||||
elif event.is_action_pressed("dash"):
|
elif event.is_action_pressed("dash"):
|
||||||
@@ -273,6 +279,17 @@ func _physics_process(delta: float) -> void:
|
|||||||
_process_death(delta)
|
_process_death(delta)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if input_frozen:
|
||||||
|
# Стоим на месте (только падаем, если под ногами пусто)
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity += get_gravity() * delta
|
||||||
|
else:
|
||||||
|
velocity.y = 0.0
|
||||||
|
move_and_slide()
|
||||||
|
return
|
||||||
|
|
||||||
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
||||||
dash_cooldown_timer = maxf(dash_cooldown_timer - delta, 0.0)
|
dash_cooldown_timer = maxf(dash_cooldown_timer - delta, 0.0)
|
||||||
wallrun_cooldown_timer = maxf(wallrun_cooldown_timer - delta, 0.0)
|
wallrun_cooldown_timer = maxf(wallrun_cooldown_timer - delta, 0.0)
|
||||||
@@ -940,6 +957,23 @@ func _process_death(delta: float) -> void:
|
|||||||
if death_timer <= 0.0:
|
if death_timer <= 0.0:
|
||||||
_respawn()
|
_respawn()
|
||||||
|
|
||||||
|
# Поставить игрока в точку t (лицом по её повороту), сбросив скорость и способности. Для карт-режимов (Гонка).
|
||||||
|
func place(t: Transform3D, pitch := 0.0) -> void:
|
||||||
|
spawn_transform = t
|
||||||
|
if is_dead:
|
||||||
|
_respawn()
|
||||||
|
else:
|
||||||
|
if is_sliding:
|
||||||
|
_end_slide()
|
||||||
|
if is_crouching:
|
||||||
|
_end_crouch()
|
||||||
|
is_dashing = false
|
||||||
|
is_wallrunning = false
|
||||||
|
is_mantling = false
|
||||||
|
global_transform = t
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
head.rotation.x = pitch
|
||||||
|
|
||||||
func _respawn() -> void:
|
func _respawn() -> void:
|
||||||
global_transform = spawn_transform
|
global_transform = spawn_transform
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ dash={
|
|||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
ready={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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) нужно выкладывать вместе с index.* — клиент качает их при входе в портал.
|
# Пакеты карт (backrooms.pck, world.pck, parkour.pck, race.pck) нужно выкладывать вместе с index.* — клиент качает их при входе в портал.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
OUT="${1:-../grid-world}"
|
OUT="${1:-../grid-world}"
|
||||||
@@ -15,4 +15,5 @@ godot --headless --export-release Web "$OUT/index.html"
|
|||||||
godot --headless --export-pack Backrooms "$OUT/backrooms.pck"
|
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"
|
||||||
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}'
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
extends Node
|
||||||
|
# Бот для проверки режима «Гонка» (автозагрузка RaceBot во ВРЕМЕННОЙ копии проекта, см. tools/test/race_game_test.sh).
|
||||||
|
# Сам «жмёт R» (действие ready через Input.parse_input_event), на отсчёте пытается идти (проверка заморозки),
|
||||||
|
# в гонке бежит безопасный маршрут race_route.json (Blender-координаты; идея — бот-бегун из Blender models/race_01/tests/runner.gd).
|
||||||
|
# Аргументы (после --): --name=A --delay=<с задержки старта бега> --expect=<сколько игроков ждать перед «R»>
|
||||||
|
# --rounds=<сколько кругов пройти> --quit-race-time=<выйти из игры на этой секунде гонки>
|
||||||
|
# --die-at-gate=<n>: после ворот n умереть (проверка возрождения лицом по трассе)
|
||||||
|
# --fall-at-gate=<n>: после ворот n упасть в пустоту (проверка срыва ниже ворот)
|
||||||
|
# Факты печатаются строками «[имя] …»; сверяет их tools/test/race_game_test.sh.
|
||||||
|
|
||||||
|
const Data = preload("res://maps/race/race_data.gd")
|
||||||
|
|
||||||
|
var label := "?"
|
||||||
|
var delay := 0.0
|
||||||
|
var expect := 2
|
||||||
|
var rounds := 1
|
||||||
|
var quit_race_time := -1.0
|
||||||
|
var die_gate := -1
|
||||||
|
var fall_gate := -1
|
||||||
|
var route: Array = []
|
||||||
|
var wi := 1
|
||||||
|
var jumped_at := -100
|
||||||
|
var fr := 0
|
||||||
|
var rules
|
||||||
|
var p
|
||||||
|
var last_phase := -1
|
||||||
|
var rounds_done := 0
|
||||||
|
var last_press := -10.0
|
||||||
|
var t := 0.0
|
||||||
|
var freeze_start := Vector3.ZERO
|
||||||
|
var special_done := false
|
||||||
|
var special_wait := 0
|
||||||
|
var special_expect := Vector3.ZERO
|
||||||
|
var stuck_fr := 0
|
||||||
|
var spectator_logged := false
|
||||||
|
var music_logged := false
|
||||||
|
var music_cd_logged := false
|
||||||
|
var freeze_logged := false
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
for a in OS.get_cmdline_user_args():
|
||||||
|
if a.begins_with("--name="): label = a.substr(7)
|
||||||
|
if a.begins_with("--delay="): delay = float(a.substr(8))
|
||||||
|
if a.begins_with("--expect="): expect = int(a.substr(9))
|
||||||
|
if a.begins_with("--rounds="): rounds = int(a.substr(9))
|
||||||
|
if a.begins_with("--quit-race-time="): quit_race_time = float(a.substr(17))
|
||||||
|
if a.begins_with("--die-at-gate="): die_gate = int(a.substr(14))
|
||||||
|
if a.begins_with("--fall-at-gate="): fall_gate = int(a.substr(15))
|
||||||
|
route = JSON.parse_string(FileAccess.get_file_as_string("res://tools/test/race_route.json")).safe
|
||||||
|
|
||||||
|
func gp(w: Array) -> Vector3:
|
||||||
|
return Vector3(w[0], w[2], -w[1])
|
||||||
|
|
||||||
|
func key(a: String, down: bool) -> void:
|
||||||
|
if down: Input.action_press(a)
|
||||||
|
else: Input.action_release(a)
|
||||||
|
|
||||||
|
func ray(from: Vector3, to: Vector3) -> Dictionary:
|
||||||
|
var q := PhysicsRayQueryParameters3D.create(from, to)
|
||||||
|
q.exclude = [p.get_rid()]
|
||||||
|
return p.get_world_3d().direct_space_state.intersect_ray(q)
|
||||||
|
|
||||||
|
func say(text: String) -> void:
|
||||||
|
print("[%s] %s" % [label, text])
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
var sc := get_tree().current_scene
|
||||||
|
if sc == null or sc.name != "Race":
|
||||||
|
return
|
||||||
|
rules = sc.get_node_or_null("Rules")
|
||||||
|
p = get_tree().get_first_node_in_group("local_player")
|
||||||
|
if rules == null or p == null:
|
||||||
|
return
|
||||||
|
fr += 1
|
||||||
|
t += _delta
|
||||||
|
var ph: int = rules.phase
|
||||||
|
if ph != last_phase:
|
||||||
|
say("ФАЗА %s, unix=%.3f, место_в_слоте=(%.1f, %.1f, %.1f)" % [rules.Phase.keys()[ph], Time.get_unix_time_from_system(), p.global_position.x, p.global_position.y, p.global_position.z])
|
||||||
|
if ph == rules.Phase.COUNTDOWN:
|
||||||
|
freeze_start = p.global_position
|
||||||
|
if ph == rules.Phase.WAIT and last_phase == rules.Phase.RESULTS:
|
||||||
|
rounds_done += 1
|
||||||
|
say("КРУГОВ_ЗАВЕРШЕНО %d" % rounds_done)
|
||||||
|
if rounds_done >= rounds:
|
||||||
|
say("ГОТОВО, выхожу")
|
||||||
|
get_tree().quit()
|
||||||
|
if ph == rules.Phase.RACE:
|
||||||
|
wi = 1
|
||||||
|
special_done = false
|
||||||
|
music_logged = false
|
||||||
|
music_cd_logged = false
|
||||||
|
freeze_logged = false
|
||||||
|
say("УЧАСТНИК=%s" % rules.participants.has(rules.my_id))
|
||||||
|
|
||||||
|
last_phase = ph
|
||||||
|
match ph:
|
||||||
|
rules.Phase.WAIT:
|
||||||
|
_wait()
|
||||||
|
rules.Phase.COUNTDOWN:
|
||||||
|
# пытаемся бежать: заморозка не должна пускать
|
||||||
|
key("move_forward", true)
|
||||||
|
key("sprint", true)
|
||||||
|
key("jump", true)
|
||||||
|
if (Time.get_ticks_msec() + 3000) >= rules.start_tick and not music_cd_logged:
|
||||||
|
music_cd_logged = true
|
||||||
|
say("МУЗЫКА на отсчёте: %s" % _music_playing())
|
||||||
|
if (Time.get_ticks_msec() + 300) >= rules.start_tick and not freeze_logged:
|
||||||
|
freeze_logged = true
|
||||||
|
say("ЗАМОРОЗКА: сдвиг за отсчёт %.3f м (по горизонтали)" % Vector2(p.global_position.x - freeze_start.x, p.global_position.z - freeze_start.z).length())
|
||||||
|
rules.Phase.RACE:
|
||||||
|
if not music_logged and rules.race_time() > 1.0:
|
||||||
|
music_logged = true
|
||||||
|
say("МУЗЫКА после старта: играет=%s" % _music_playing())
|
||||||
|
_race()
|
||||||
|
_:
|
||||||
|
for k in ["move_forward", "sprint", "jump"]:
|
||||||
|
key(k, false)
|
||||||
|
|
||||||
|
func _wait() -> void:
|
||||||
|
for k in ["move_forward", "sprint", "jump"]:
|
||||||
|
key(k, false)
|
||||||
|
if rules.waiting_for_race():
|
||||||
|
if not spectator_logged:
|
||||||
|
spectator_logged = true
|
||||||
|
say("ГОНКА_ИДЁТ: жду следующий круг (участник=%s)" % rules.participants.has(rules.my_id))
|
||||||
|
return
|
||||||
|
if rules.flags_ready.get(rules.my_id, false):
|
||||||
|
return
|
||||||
|
if Net.ghosts.size() < expect - 1 or t - last_press < 1.5:
|
||||||
|
return
|
||||||
|
last_press = t
|
||||||
|
spectator_logged = false
|
||||||
|
var ev := InputEventAction.new()
|
||||||
|
ev.action = "ready"
|
||||||
|
ev.pressed = true
|
||||||
|
Input.parse_input_event(ev)
|
||||||
|
say("жму R, видно игроков: %d" % (Net.ghosts.size() + 1))
|
||||||
|
|
||||||
|
func _race() -> void:
|
||||||
|
if not rules.participants.has(rules.my_id):
|
||||||
|
return # бегаем свободно без зачёта — бот просто стоит
|
||||||
|
if quit_race_time >= 0.0 and rules.race_time() >= quit_race_time:
|
||||||
|
say("ВЫХОЖУ из игры в разгар гонки (время %.1f)" % rules.race_time())
|
||||||
|
get_tree().quit()
|
||||||
|
return
|
||||||
|
if rules.finish.has(rules.my_id):
|
||||||
|
for k in ["move_forward", "sprint", "jump"]:
|
||||||
|
key(k, false)
|
||||||
|
return
|
||||||
|
if rules.race_time() < delay:
|
||||||
|
return
|
||||||
|
# --- проверки возрождения ---
|
||||||
|
var g: int = rules.passed.get(rules.my_id, 0)
|
||||||
|
if not special_done and die_gate >= 0 and g == die_gate and not p.is_dead:
|
||||||
|
special_done = true
|
||||||
|
special_wait = 1
|
||||||
|
say("УМИРАЮ после ворот %d" % g)
|
||||||
|
p.die()
|
||||||
|
elif not special_done and fall_gate >= 0 and g == fall_gate and not p.is_dead:
|
||||||
|
special_done = true
|
||||||
|
special_wait = 1
|
||||||
|
say("ПАДАЮ в пустоту после ворот %d" % g)
|
||||||
|
p.global_position.y -= 45.0
|
||||||
|
p.velocity = Vector3.ZERO
|
||||||
|
if special_wait > 0:
|
||||||
|
if p.is_dead:
|
||||||
|
special_wait = 2
|
||||||
|
elif special_wait == 2:
|
||||||
|
special_wait = 0
|
||||||
|
var f: Vector3 = -p.global_transform.basis.z
|
||||||
|
var ex: float = rules.map.gate_yaw(g - 1)
|
||||||
|
var want := Vector3(-sin(ex), 0, -cos(ex))
|
||||||
|
say("ВОЗРОЖДЕНИЕ у ворот %d: взгляд (%.2f, %.2f), ожидался (%.2f, %.2f), позиция (%.1f, %.1f, %.1f), ворота (%.1f, %.1f, %.1f) -> %s" % [
|
||||||
|
g, f.x, f.z, want.x, want.z, p.global_position.x, p.global_position.y, p.global_position.z,
|
||||||
|
rules.map.gate_pos(g - 1).x, rules.map.gate_pos(g - 1).y, rules.map.gate_pos(g - 1).z,
|
||||||
|
"ВЕРНО" if Vector2(f.x, f.z).distance_to(Vector2(want.x, want.z)) < 0.05 and p.global_position.distance_to(rules.map.gate_pos(g - 1)) < 1.0 else "ОШИБКА"])
|
||||||
|
return
|
||||||
|
_drive()
|
||||||
|
|
||||||
|
func _drive() -> void:
|
||||||
|
var pos: Vector3 = p.global_position
|
||||||
|
if fr % 120 == 0 and OS.get_cmdline_user_args().has("--bot-log"):
|
||||||
|
say("бег: wi=%d поз=(%.1f, %.1f, %.1f) v=(%.1f, %.1f) пол=%s" % [wi, pos.x, pos.y, pos.z, p.velocity.x, p.velocity.z, p.is_on_floor()])
|
||||||
|
var tgt := gp(route[wi])
|
||||||
|
var to := Vector2(tgt.x - pos.x, tgt.z - pos.z)
|
||||||
|
var seg_a := gp(route[wi - 1])
|
||||||
|
var seg := Vector2(tgt.x - seg_a.x, tgt.z - seg_a.z)
|
||||||
|
var reached := to.length() < 2.5 or (to.dot(seg) < 0.0 and to.length() < 8.0)
|
||||||
|
if _at_gate(tgt): # точка маршрута на воротах: считается взятой, только когда плоскость ворот уже пересечена
|
||||||
|
reached = to.dot(seg) < 0.0 and to.length() < 12.0
|
||||||
|
if reached and wi < route.size() - 1:
|
||||||
|
wi += 1
|
||||||
|
tgt = gp(route[wi])
|
||||||
|
to = Vector2(tgt.x - pos.x, tgt.z - pos.z)
|
||||||
|
if p.is_dead:
|
||||||
|
return
|
||||||
|
key("move_forward", true)
|
||||||
|
key("sprint", true)
|
||||||
|
var dir := to.normalized()
|
||||||
|
p.rotation.y = atan2(-dir.x, -dir.y)
|
||||||
|
# прыжок: край впереди или препятствие
|
||||||
|
var jump := false
|
||||||
|
if p.is_on_floor() and fr - jumped_at > 12:
|
||||||
|
var ahead := Vector3(dir.x, 0, dir.y) * (0.5 + Vector2(p.velocity.x, p.velocity.z).length() * 0.045)
|
||||||
|
var probe := pos + ahead
|
||||||
|
if ray(probe, probe + Vector3(0, -2.6, 0)).is_empty():
|
||||||
|
jump = true
|
||||||
|
if not ray(pos + Vector3(0, -0.5, 0), pos + Vector3(0, -0.5, 0) + Vector3(dir.x, 0, dir.y) * 1.4).is_empty():
|
||||||
|
jump = true
|
||||||
|
if jump:
|
||||||
|
jumped_at = fr
|
||||||
|
key("jump", jump or fr - jumped_at < 22)
|
||||||
|
|
||||||
|
func _music_playing() -> bool:
|
||||||
|
for n in get_tree().current_scene.get_children():
|
||||||
|
if n is AudioStreamPlayer and n.stream is AudioStreamOggVorbis and n.playing:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _at_gate(w: Vector3) -> bool:
|
||||||
|
for g in Data.CHECKPOINTS:
|
||||||
|
if Vector2(g[0].x - w.x, g[0].z - w.z).length() < 3.0:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://w0j6xo7y5o6n
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Проверка карты Race «по существу» после прохода через портал RACE с настоящими пакетами (по образцу parkour_check.txt).
|
||||||
|
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...")
|
||||||
|
# Пакеты: tools/export_all.sh /tmp/site (НЕ в grid-world), затем (cd /tmp/site && python3 -m http.server 8777 --bind 127.0.0.1 &) и
|
||||||
|
# godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:6.75,1,-7.5
|
||||||
|
# (портал RACE четвёртый в ряду: x = (3 - 1.5) * 4.5 = 6.75). Сервер в этом тесте не нужен.
|
||||||
|
# Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true.
|
||||||
|
|
||||||
|
var sc := get_tree().current_scene
|
||||||
|
if sc != null and sc.name == "Race" 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(" ПРОВЕРКА Race: скрипт=%s правила=%s шейдерных_мешей=%d небо_панорама=%s ворот=%d лазеров=%d музыка_в_файле=%s" % [
|
||||||
|
sc.get_script() != null, sc.get_node_or_null("Rules") != null, shaded, sky_ok, sc._gates.size(), sc._lasers.size(),
|
||||||
|
ResourceLoader.exists("res://maps/race/audio/music.ogg")])
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Сверка логов race_game_test.sh: A, B (два круга), C (зашёл в разгар круга 1, участвует в круге 2 и выходит)."""
|
||||||
|
import re, sys, os
|
||||||
|
|
||||||
|
W = sys.argv[1]
|
||||||
|
logs = {n: open(os.path.join(W, n + ".log"), errors="replace").read().splitlines() for n in "ABC"}
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
def check(cond, text):
|
||||||
|
global ok
|
||||||
|
print((" ДА " if cond else " НЕТ ") + text)
|
||||||
|
if not cond:
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
def rounds_of(lines):
|
||||||
|
"""Таблицы итогов по кругам: список [ [(место, id, время, ворот)...], ... ]"""
|
||||||
|
res, cur = [], []
|
||||||
|
for l in lines:
|
||||||
|
m = re.search(r"итог: место (\d+) игрок (-?\d+) время (-?[\d.]+) ворот (\d+)", l)
|
||||||
|
if m:
|
||||||
|
if cur and int(m.group(1)) == 1:
|
||||||
|
res.append(cur); cur = []
|
||||||
|
cur.append((int(m.group(1)), int(m.group(2)), float(m.group(3)), int(m.group(4))))
|
||||||
|
elif cur and "фаза -> WAIT" in l:
|
||||||
|
res.append(cur); cur = []
|
||||||
|
if cur: res.append(cur)
|
||||||
|
return res
|
||||||
|
|
||||||
|
ra, rb = rounds_of(logs["A"]), rounds_of(logs["B"])
|
||||||
|
print("Итоговые таблицы A:", ra)
|
||||||
|
print("Итоговые таблицы B:", rb)
|
||||||
|
check(len(ra) == 2 and len(rb) == 2, "оба клиента показали итоги двух кругов (%d и %d)" % (len(ra), len(rb)))
|
||||||
|
for i in range(min(len(ra), len(rb))):
|
||||||
|
check(ra[i] == rb[i], "круг %d: таблица мест одинакова на A и B" % (i + 1))
|
||||||
|
if ra[i]:
|
||||||
|
times = [r[2] for r in ra[i]]
|
||||||
|
check(len(ra[i]) == 2 and all(t > 0 for t in times) and len(set(times)) == len(times),
|
||||||
|
"круг %d: два финиша с разным временем %s" % (i + 1, times))
|
||||||
|
check([r[0] for r in ra[i]] == [1, 2] and ra[i][0][2] < ra[i][1][2], "круг %d: места 1 и 2 по возрастанию времени" % (i + 1))
|
||||||
|
if len(ra) == 2:
|
||||||
|
check(len(ra[1]) == 2, "круг 2: C вышел в разгар и убран из итогов (в таблице %d строк)" % len(ra[1]))
|
||||||
|
|
||||||
|
# один START на круг
|
||||||
|
starts = sum(1 for n in "ABC" for l in logs[n] if "даю старт" in l)
|
||||||
|
check(starts == 2, "команду START дал один клиент на круг (всего %d за 2 круга)" % starts)
|
||||||
|
for n in "AB":
|
||||||
|
senders = re.findall(r"старт от (\d+): слот (\d+) из (\d+)", "\n".join(logs[n]))
|
||||||
|
print(" старт на %s: %s" % (n, senders))
|
||||||
|
sa = re.findall(r"старт от (\d+): слот (\d+) из (\d+)", "\n".join(logs["A"]))
|
||||||
|
sb = re.findall(r"старт от (\d+): слот (\d+) из (\d+)", "\n".join(logs["B"]))
|
||||||
|
sc = re.findall(r"старт от (\d+): слот (\d+) из (\d+)", "\n".join(logs["C"]))
|
||||||
|
check(len(sa) == 2 and len(sb) == 2 and [s[0] for s in sa] == [s[0] for s in sb], "A и B приняли одних и тех же отправителей START в обоих кругах")
|
||||||
|
if len(sa) == 2 and len(sb) == 2:
|
||||||
|
check(sa[0][2] == "2" and sa[1][2] == "3", "в круге 1 участвовали 2, в круге 2 — 3 игрока (A: %s)" % [s[2] for s in sa])
|
||||||
|
check(sorted([sa[1][1], sb[1][1], sc[0][1] if sc else "?"]) == ["0", "1", "2"] and sa[0][1] != sb[0][1], "слоты в кругах различны и подряд")
|
||||||
|
# синхронность старта по unix-времени (фаза RACE)
|
||||||
|
def race_unix(lines):
|
||||||
|
return [float(m.group(1)) for l in lines for m in [re.search(r"ФАЗА RACE, unix=([\d.]+)", l)] if m]
|
||||||
|
ua, ub = race_unix(logs["A"]), race_unix(logs["B"])
|
||||||
|
print(" unix начала гонки A:", ua, " B:", ub)
|
||||||
|
check(len(ua) == 2 and len(ub) == 2 and all(abs(x - y) < 0.35 for x, y in zip(ua, ub)), "начало гонки на A и B совпадает в пределах 0,35 с: %s" % [round(x - y, 3) for x, y in zip(ua, ub)])
|
||||||
|
# заморозка
|
||||||
|
fr = [float(m.group(1)) for n in "ABC" for l in logs[n] for m in [re.search(r"ЗАМОРОЗКА: сдвиг за отсчёт ([\d.]+)", l)] if m]
|
||||||
|
check(len(fr) >= 5 and max(fr) < 0.01, "заморозка на отсчёте: смещения %s" % fr)
|
||||||
|
# музыка
|
||||||
|
mus_after = [l for n in "AB" for l in logs[n] if "МУЗЫКА после старта" in l]
|
||||||
|
mus_cd = [l for n in "AB" for l in logs[n] if "МУЗЫКА на отсчёте" in l]
|
||||||
|
check(len(mus_after) >= 4 and all("играет=true" in l for l in mus_after), "музыка играет после старта (%d проверок)" % len(mus_after))
|
||||||
|
check(len(mus_cd) >= 4 and all("false" in l for l in mus_cd), "на отсчёте музыки нет (%d проверок)" % len(mus_cd))
|
||||||
|
# возрождение
|
||||||
|
resp = [l for n in "AB" for l in logs[n] if "ВОЗРОЖДЕНИЕ" in l]
|
||||||
|
for l in resp: print(" ", l)
|
||||||
|
check(len(resp) >= 4 and all("-> ВЕРНО" in l for l in resp), "возрождение у ворот лицом по трассе (%d проверок, ошибок нет)" % len(resp))
|
||||||
|
# C: зритель, потом участник
|
||||||
|
c = "\n".join(logs["C"])
|
||||||
|
check("ГОНКА_ИДЁТ" in c and "участник=false" in c, "C зашёл в разгар круга 1: «гонка идёт», без зачёта")
|
||||||
|
check("УЧАСТНИК=true" in c, "C участвует в круге 2")
|
||||||
|
check("ВЫХОЖУ из игры" in c, "C вышел в разгар круга 2")
|
||||||
|
for n in "ABC":
|
||||||
|
errs = [l for l in logs[n] if "SCRIPT ERROR" in l or (l.startswith("ERROR") and "still in use at exit" not in l) or "Parse Error" in l]
|
||||||
|
check(not errs, "в логе %s нет ошибок скриптов %s" % (n, errs[:2]))
|
||||||
|
check("ГОТОВО" in "\n".join(logs["A"]) and "ГОТОВО" in "\n".join(logs["B"]), "A и B дошли до конца теста")
|
||||||
|
print("\nПРОВЕРКА ПРОЙДЕНА" if ok else "\nПРОВЕРКА ПРОВАЛЕНА")
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Игровая проверка режима «Гонка» на локальном сервере: 2 клиента-бота (+ третий, который заходит в разгар круга 1,
|
||||||
|
# наблюдает, участвует в круге 2 и выходит посреди него). Окно не открывается.
|
||||||
|
# tools/test/race_game_test.sh [рабочая_папка=/tmp/race_game]
|
||||||
|
# Печатает факты и итог «ПРОВЕРКА ПРОЙДЕНА / ПРОВАЛЕНА». Порт сервера 9080 должен быть свободен.
|
||||||
|
# Настоящий рекорд (user://race_best.cfg) не трогается: рекорд ботов идёт в файлы в рабочей папке.
|
||||||
|
set -uo pipefail
|
||||||
|
SRC="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
W="${1:-/tmp/race_game}"
|
||||||
|
rm -rf "$W"; mkdir -p "$W"
|
||||||
|
"$SRC/tools/test/make_test_server.sh" "$W/server" >/dev/null
|
||||||
|
"$SRC/tools/test/make_test_project.sh" "$W/project" >/dev/null
|
||||||
|
sed -i 's#^Bot="\*res://tools/test/bot.gd"#Bot="*res://tools/test/bot.gd"\nRaceBot="*res://tools/test/race_bot.gd"#' "$W/project/project.godot"
|
||||||
|
sed -i 's#^config/name="world2"#config/name="world2-racetest"#' "$W/project/project.godot"
|
||||||
|
(cd "$W/project" && godot --headless --import --path . >/dev/null 2>&1)
|
||||||
|
|
||||||
|
godot --headless --path "$W/server" > "$W/server.log" 2>&1 &
|
||||||
|
SERVER=$!
|
||||||
|
sleep 2
|
||||||
|
run() { # имя, остальные аргументы бота
|
||||||
|
local n="$1"; shift
|
||||||
|
timeout 420 godot --headless --path "$W/project" res://maps/race/race.tscn -- --client --race-log --race-best="$W/best_$n.cfg" --name="$n" "$@" > "$W/$n.log" 2>&1 &
|
||||||
|
}
|
||||||
|
run A --expect=2 --rounds=2 --die-at-gate=3
|
||||||
|
PA=$!
|
||||||
|
run B --expect=2 --rounds=2 --delay=2.0 --fall-at-gate=2
|
||||||
|
PB=$!
|
||||||
|
sleep 40 # круг 1 в разгаре (отсчёт 6 с + бег)
|
||||||
|
run C --expect=2 --rounds=1 --quit-race-time=12
|
||||||
|
PC=$!
|
||||||
|
sleep 5; ps -eo pid,args | grep '[g]odot' | cut -c1-200 > "$W/ps_after_C.txt"
|
||||||
|
wait $PA $PB $PC
|
||||||
|
kill $SERVER 2>/dev/null
|
||||||
|
echo "логи: $W/{A,B,C,server}.log"
|
||||||
|
python3 "$SRC/tools/test/race_game_check.py" "$W"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"safe": [[0, 28.0, 0], [0, 40, 0], [6, 50, 0], [6, 58, 0], [0, 64, 0], [0, 84, 0], [0, 96, 1.5], [0, 106, 3.0], [0, 120, 4.5], [0, 134, 4.5], [0, 150, 4.5], [0, 172, 4.5], [0, 190, 4.5], [20, 195, 4.5], [60, 195, 4.5], [99, 195, 5.0], [150, 195, 3.5], [138, 216, -0.7999999999999998], [116, 254, -0.7999999999999998], [114, 270, -0.7999999999999998], [143, 276, -0.7999999999999998], [143, 296, -0.7999999999999998], [160, 304, -0.7999999999999998], [162, 331, -2.8], [162, 347, -4.9], [162, 358, -6.9], [163.5, 366, -6.9], [163.5, 373.5, -6.9], [151.5, 381, -6.9], [163.5, 389, -6.9], [151.5, 397, -6.9], [163.5, 405, -6.9], [163.5, 416, -6.9], [161, 428, -6.9], [161, 440.0, -6.9], [161, 462.0, -6.9]], "finish": 440.0}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
extends Node
|
||||||
|
# Облёт карты Race для скриншотов (окно мелькнёт; кадры пишет Godot через --write-movie).
|
||||||
|
# --mode=gates (по умолчанию): телепорт игрока к воротам и видам трассы, смотрит вдоль трассы
|
||||||
|
# --mode=hud: «жмёт R», ждёт отсчёт, пробегает ворота (телепортом перед воротами + шаг вперёд), финиш, итоги
|
||||||
|
# Запуск:
|
||||||
|
# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/r.png --quit-after 60 res://tools/test/race_tour.tscn -- --client
|
||||||
|
# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/h.png --quit-after 260 res://tools/test/race_tour.tscn -- --client --mode=hud --race-best=/tmp/shots/best.cfg
|
||||||
|
const Data = preload("res://maps/race/race_data.gd")
|
||||||
|
var level
|
||||||
|
var p
|
||||||
|
var frame := 0
|
||||||
|
var mode := "gates"
|
||||||
|
var gate := 0
|
||||||
|
# (позиция центра капсулы, рыскание°, наклон°) — виды вдоль трассы
|
||||||
|
var spots := [
|
||||||
|
[Vector3(0, 1.25, -20), 0, 4], [Vector3(0, 1.25, -70), 0, 4], [Vector3(0, 5.7, -100), 0, 4], [Vector3(40, 5.7, -195), -90, 4],
|
||||||
|
[Vector3(120, 5.2, -195), -90, 4], [Vector3(163, 0.4, -290), 0, 4], [Vector3(162, -5.7, -350), 0, 4], [Vector3(161, -5.7, -420), 0, 4],
|
||||||
|
[Vector3(161, -5.7, -470), 0, 4], [Vector3(161, -6.7, -495), 180, 4],
|
||||||
|
]
|
||||||
|
func _ready() -> void:
|
||||||
|
for a in OS.get_cmdline_user_args():
|
||||||
|
if a.begins_with("--mode="):
|
||||||
|
mode = a.substr(7)
|
||||||
|
level = (load("res://maps/race/race.tscn") as PackedScene).instantiate()
|
||||||
|
add_child(level)
|
||||||
|
p = level.get_node("Player")
|
||||||
|
if mode == "gates":
|
||||||
|
p.set_physics_process(false)
|
||||||
|
func _process(_d: float) -> void:
|
||||||
|
frame += 1
|
||||||
|
if mode == "gates":
|
||||||
|
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])
|
||||||
|
return
|
||||||
|
var rules = level.get_node("Rules")
|
||||||
|
if frame == 8:
|
||||||
|
rules.toggle_ready()
|
||||||
|
# после старта: каждые 25 кадров ставим игрока за 4 м до очередных ворот лицом вперёд и бежим
|
||||||
|
if rules.phase == 2 and not rules.finish.has(rules.my_id) and frame % 25 == 0:
|
||||||
|
var g: int = rules.passed.get(rules.my_id, 0)
|
||||||
|
if g < Data.CHECKPOINTS.size():
|
||||||
|
var gp: Vector3 = Data.CHECKPOINTS[g][0]
|
||||||
|
var yaw := deg_to_rad(Data.CHECKPOINTS[g][2])
|
||||||
|
if g < 6:
|
||||||
|
p.global_position = gp - Vector3(-sin(yaw), 0, -cos(yaw)) * 6.0
|
||||||
|
p.rotation.y = yaw
|
||||||
|
p.velocity = Vector3.ZERO
|
||||||
|
Input.action_press("move_forward")
|
||||||
|
Input.action_press("sprint")
|
||||||
|
else: # дальше просто засчитываем ворота (облёт проверяет только HUD; проход трассы — в race_game_test)
|
||||||
|
Input.action_release("move_forward")
|
||||||
|
Input.action_release("sprint")
|
||||||
|
p.global_position = gp
|
||||||
|
rules._pass_gate(g)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bb65tfdst1g06
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tools/test/race_tour.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="T" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
Reference in New Issue
Block a user