diff --git a/README.md b/README.md index d1790d2..cc3c2c9 100644 --- a/README.md +++ b/README.md @@ -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». + ## Дорожная карта ### Цель: играть с телефона diff --git a/export_presets.cfg b/export_presets.cfg index 535ddf7..2fd9865 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -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 diff --git a/maps/hub/hub.gd b/maps/hub/hub.gd index 0518e2e..1dc1797 100644 --- a/maps/hub/hub.gd +++ b/maps/hub/hub.gd @@ -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 diff --git a/maps/race/audio/music.ogg b/maps/race/audio/music.ogg new file mode 100644 index 0000000..2ed82f8 Binary files /dev/null and b/maps/race/audio/music.ogg differ diff --git a/maps/race/audio/music.ogg.import b/maps/race/audio/music.ogg.import new file mode 100644 index 0000000..bd68609 --- /dev/null +++ b/maps/race/audio/music.ogg.import @@ -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 diff --git a/maps/race/baked.gdshader b/maps/race/baked.gdshader new file mode 100644 index 0000000..efad6aa --- /dev/null +++ b/maps/race/baked.gdshader @@ -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)); +} diff --git a/maps/race/baked.gdshader.uid b/maps/race/baked.gdshader.uid new file mode 100644 index 0000000..d2fec96 --- /dev/null +++ b/maps/race/baked.gdshader.uid @@ -0,0 +1 @@ +uid://dn0bcs7hv627b diff --git a/maps/race/race.gd b/maps/race/race.gd new file mode 100644 index 0000000..79be4e3 --- /dev/null +++ b/maps/race/race.gd @@ -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) diff --git a/maps/race/race.gd.uid b/maps/race/race.gd.uid new file mode 100644 index 0000000..644a0b6 --- /dev/null +++ b/maps/race/race.gd.uid @@ -0,0 +1 @@ +uid://c18dfdsjr2uje diff --git a/maps/race/race.glb b/maps/race/race.glb new file mode 100644 index 0000000..ce8ac41 Binary files /dev/null and b/maps/race/race.glb differ diff --git a/maps/race/race.glb.import b/maps/race/race.glb.import new file mode 100644 index 0000000..a2d69c9 --- /dev/null +++ b/maps/race/race.glb.import @@ -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 diff --git a/maps/race/race.tscn b/maps/race/race.tscn new file mode 100644 index 0000000..073cc21 --- /dev/null +++ b/maps/race/race.tscn @@ -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")] diff --git a/maps/race/race_data.gd b/maps/race/race_data.gd new file mode 100644 index 0000000..41962c2 --- /dev/null +++ b/maps/race/race_data.gd @@ -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 diff --git a/maps/race/race_data.gd.uid b/maps/race/race_data.gd.uid new file mode 100644 index 0000000..813944b --- /dev/null +++ b/maps/race/race_data.gd.uid @@ -0,0 +1 @@ +uid://dowlbtftiudoh diff --git a/maps/race/race_rules.gd b/maps/race/race_rules.gd new file mode 100644 index 0000000..c7b04f0 --- /dev/null +++ b/maps/race/race_rules.gd @@ -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 diff --git a/maps/race/race_rules.gd.uid b/maps/race/race_rules.gd.uid new file mode 100644 index 0000000..40df3a7 --- /dev/null +++ b/maps/race/race_rules.gd.uid @@ -0,0 +1 @@ +uid://b67m05gbpouwi diff --git a/maps/race/sky.png b/maps/race/sky.png new file mode 100644 index 0000000..7a850e3 Binary files /dev/null and b/maps/race/sky.png differ diff --git a/maps/race/sky.png.import b/maps/race/sky.png.import new file mode 100644 index 0000000..de4fa9b --- /dev/null +++ b/maps/race/sky.png.import @@ -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 diff --git a/net/net.gd b/net/net.gd index 3ae3cff..e7b46b8 100644 --- a/net/net.gd +++ b/net/net.gd @@ -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) diff --git a/player/player.gd b/player/player.gd index ac31a0e..2fefa48 100644 --- a/player/player.gd +++ b/player/player.gd @@ -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 diff --git a/project.godot b/project.godot index ccfad7a..ceb5e51 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/tools/export_all.sh b/tools/export_all.sh index c4e961a..76f1c76 100755 --- a/tools/export_all.sh +++ b/tools/export_all.sh @@ -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}' diff --git a/tools/test/race_bot.gd b/tools/test/race_bot.gd new file mode 100644 index 0000000..97d0395 --- /dev/null +++ b/tools/test/race_bot.gd @@ -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 умереть (проверка возрождения лицом по трассе) +# --fall-at-gate=: после ворот 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 diff --git a/tools/test/race_bot.gd.uid b/tools/test/race_bot.gd.uid new file mode 100644 index 0000000..05371b4 --- /dev/null +++ b/tools/test/race_bot.gd.uid @@ -0,0 +1 @@ +uid://w0j6xo7y5o6n diff --git a/tools/test/race_check.txt b/tools/test/race_check.txt new file mode 100644 index 0000000..c422544 --- /dev/null +++ b/tools/test/race_check.txt @@ -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")]) diff --git a/tools/test/race_game_check.py b/tools/test/race_game_check.py new file mode 100755 index 0000000..fa39723 --- /dev/null +++ b/tools/test/race_game_check.py @@ -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) diff --git a/tools/test/race_game_test.sh b/tools/test/race_game_test.sh new file mode 100755 index 0000000..f733832 --- /dev/null +++ b/tools/test/race_game_test.sh @@ -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" diff --git a/tools/test/race_route.json b/tools/test/race_route.json new file mode 100644 index 0000000..4ae0f47 --- /dev/null +++ b/tools/test/race_route.json @@ -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} \ No newline at end of file diff --git a/tools/test/race_tour.gd b/tools/test/race_tour.gd new file mode 100644 index 0000000..e580ed0 --- /dev/null +++ b/tools/test/race_tour.gd @@ -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) diff --git a/tools/test/race_tour.gd.uid b/tools/test/race_tour.gd.uid new file mode 100644 index 0000000..3193126 --- /dev/null +++ b/tools/test/race_tour.gd.uid @@ -0,0 +1 @@ +uid://bb65tfdst1g06 diff --git a/tools/test/race_tour.tscn b/tools/test/race_tour.tscn new file mode 100644 index 0000000..657b3d3 --- /dev/null +++ b/tools/test/race_tour.tscn @@ -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")