Карта 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:
2026-10-28 17:28:05 +05:00
co-authored by Claude Sonnet 5.5
parent 2a6397da79
commit 9130202a51
31 changed files with 1775 additions and 4 deletions
+47 -2
View File
@@ -42,12 +42,12 @@ godot --path . # можно запустить несколько ок
```
Карты в редакторе запускаются отдельно (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, капсула вдвое ниже, пролезает под низкое) |
| Shift + C на бегу | **подкат** |
| Q | рывок |
| R | «готов» (только на карте Race) |
| ЛКМ (зажать) | зум |
| Esc / клик | отпустить / захватить мышь |
@@ -85,6 +86,7 @@ maps/ у каждой карты своя папка — он
hub/ стартовая комната: hub.tscn, hub.gd (порталы строятся из списка PORTALS)
sandbox/ песочница: sandbox.tscn, forest.gd, birds.gd, levitate.gd, nav_bake.gd,
floor/ sky/ audio/ models/ (здания, башня, обелиск, деревья, Эмпайр-стейт…)
race/ race.tscn, race.gd, race_rules.gd (правила гонки), race_data.gd (генерируется), race.glb, sky.png, audio/music.ogg
parkour/ parkour.tscn, parkour.gd (логика: чекпоинты, лазеры, дождь, звук, окружение), parkour_data.gd (генерируется),
baked.gdshader, parkour.glb (из Blender), sky.png (панорама), audio/music.ogg; см. «Заметки для будущего меня»
backrooms/ backrooms.tscn, backrooms.gd, backrooms_nav.res (запечённый навмеш),
@@ -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» возвращает в хаб |
| 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`), играет по кругу |
| Race | `maps/race/race.tscn` | `race.pck` | Гонка на ногах до 8 игроков (кто быстрее до финиша), мрачный город: стартовая площадка → рынок → эстакада → крыши → каньон → ангар. 8 ворот-чекпоинтов, 2 лазера, развилки с обходами. Правила и сеть — раздел «Карта Race» ниже |
**Правка и пересборка Parkour** (всё в `Blender models/parkour_02/`):
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`.
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`.
@@ -310,6 +348,13 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок
- Не мерялось: нагрузка Parkour в браузере (~145 тыс. треугольников + дождь 2200 частиц, небо ~6 МБ в видеопамяти), звук (шум дождя, гул, песня) на слух.
- Расходный ресурс: `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
View File
@@ -10,7 +10,7 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
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"
patches=PackedStringArray()
patch_delta_encoding=false
@@ -200,3 +200,53 @@ progressive_web_app/icon_512x512=""
progressive_web_app/background_color=Color(0, 0, 0, 1)
threads/emscripten_pool_size=8
threads/godot_pool_size=4
[preset.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
+1
View File
@@ -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": "BACKROOMS", "map": "backrooms", "scene": "res://maps/backrooms/backrooms.tscn", "pack": "backrooms.pck", "color": Color(1.0, 0.85, 0.25)},
{"title": "PARKOUR", "map": "parkour", "scene": "res://maps/parkour/parkour.tscn", "pack": "parkour.pck", "color": Color(1.0, 0.2, 0.65)},
{"title": "RACE", "map": "race", "scene": "res://maps/race/race.tscn", "pack": "race.pck", "color": Color(1.0, 0.55, 0.12)},
]
const ROOM_WIDTH := 28.0
Binary file not shown.
+19
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
shader_type spatial;
// Поверхности уровня Parkour: освещение запечено в цвета вершин (Blender, Cycles), динамических источников нет.
// Итог = цвет материала × запечённый свет × мелкое «зерно» бетона (по мировым координатам) + слабый блик на скользящих углах.
render_mode unshaded;
uniform vec3 albedo_color = vec3(0.1);
uniform float light_scale = 8.0; // цвета вершин сжаты в 8 раз при экспорте (см. bake.py)
uniform float exposure = 1.0;
uniform sampler2D grain : repeat_enable, filter_linear_mipmap;
uniform float grain_scale = 0.3;
uniform float grain_amount = 0.45;
uniform sampler2D sky_tex : source_color, filter_linear_mipmap; // панорама неба: отражается в «мокрых» горизонтальных поверхностях
uniform float wet = 0.6;
varying vec3 wpos;
varying vec3 wnorm;
void vertex() {
wpos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
wnorm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
}
vec2 pano_uv(vec3 d) {
return vec2(atan(d.x, -d.z) / (2.0 * PI) + 0.5, acos(clamp(d.y, -1.0, 1.0)) / PI);
}
void fragment() {
vec3 n = abs(wnorm);
n /= (n.x + n.y + n.z + 0.0001);
float g = texture(grain, wpos.zy * grain_scale).r * n.x
+ texture(grain, wpos.xz * grain_scale).r * n.y
+ texture(grain, wpos.xy * grain_scale).r * n.z;
float gr = mix(1.0 - grain_amount * 0.5, 1.0 + grain_amount * 0.5, g);
vec3 lit = COLOR.rgb * light_scale;
vec3 nv = normalize(NORMAL);
float ndv = clamp(dot(nv, normalize(VIEW)), 0.0, 1.0);
float fres = 0.05 + 0.95 * pow(1.0 - ndv, 4.0);
vec3 rw = normalize((INV_VIEW_MATRIX * vec4(reflect(-normalize(VIEW), nv), 0.0)).xyz);
float up = smoothstep(0.6, 0.95, wnorm.y); // блестят только горизонтальные поверхности
vec3 refl = textureLod(sky_tex, pano_uv(rw), 3.0).rgb;
ALBEDO = albedo_color * lit * gr * exposure + refl * fres * wet * up * (0.5 + 0.5 * min(lit.r + lit.g + lit.b, 1.0));
}
+1
View File
@@ -0,0 +1 @@
uid://dn0bcs7hv627b
+354
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://c18dfdsjr2uje
Binary file not shown.
+45
View File
@@ -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
+12
View File
@@ -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")]
+48
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dowlbtftiudoh
+615
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b67m05gbpouwi
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

+41
View File
@@ -0,0 +1,41 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://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
View File
@@ -18,7 +18,15 @@ const FLAG_MANTLE = 8
const FLAG_WALL_RIGHT = 16 # Бег по стене: стена справа (иначе слева)
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 race_event(id: int, kind: int, a: int, b: float) # Событие гонки от игрока id (кроме нас самих)
var is_server := false
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):
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:
@@ -274,6 +291,16 @@ func recv_state(id: int, pos: Vector3, yaw: float, pitch: float, flags: int) ->
get_tree().current_scene.add_child(ghost)
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")
func recv_left(id: int) -> void:
var ghost = ghosts.get(id)
+34
View File
@@ -182,6 +182,9 @@ var spawn_transform: Transform3D
var death_overlay: ColorRect = null
var death_label: Label = null
# Заморозка ввода (режим «Гонка»: отсчёт до старта). Обзор мышью работает, движение и способности — нет.
var input_frozen: bool = false
# Коллайдер (опционально, ищется по имени CollisionShape3D)
var collision_shape: CollisionShape3D = 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.rotation.x = clamp(head.rotation.x, deg_to_rad(-80), deg_to_rad(80))
if input_frozen:
return
if event.is_action_pressed("crouch"):
_try_start_slide() # Подкат: бег (sprint) + crouch
elif event.is_action_pressed("dash"):
@@ -273,6 +279,17 @@ func _physics_process(delta: float) -> void:
_process_death(delta)
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)
dash_cooldown_timer = maxf(dash_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:
_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:
global_transform = spawn_transform
velocity = Vector3.ZERO
+5
View File
@@ -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)
]
}
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]
+2 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Экспорт веб-клиента и пакетов карт. По умолчанию — в ../grid-world (репозиторий сайта).
# tools/export_all.sh [папка_вывода]
# Пакеты карт (backrooms.pck, world.pck, parkour.pck) нужно выкладывать вместе с index.* — клиент качает их при входе в портал.
# Пакеты карт (backrooms.pck, world.pck, parkour.pck, race.pck) нужно выкладывать вместе с index.* — клиент качает их при входе в портал.
set -euo pipefail
cd "$(dirname "$0")/.."
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 World "$OUT/world.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}'
+224
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://w0j6xo7y5o6n
+18
View File
@@ -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")])
+84
View File
@@ -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)
+35
View File
@@ -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"
+1
View File
@@ -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}
+58
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bb65tfdst1g06
+6
View File
@@ -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")