diff --git a/README.md b/README.md index c7a4116..810e20f 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ | ✅ | Паркур: спринт, рывок, подкат, **присед (C)**, бег по стенам, прыжок от стены, двойной прыжок, подтягивание на уступ, зум (ЛКМ) | | ✅ | Управление **не зависит от раскладки** (русская и любая другая): действия `InputMap` на физические клавиши | | ✅ | Деплой: CI из `grid-world` на сайт; скрипты экспорта, dev-сервера, тестов без окна, выкладки сервера | -| ❌ | Управление с помощью геймпада | +| ✅ | **Геймпад Xbox** (раскладка под паркур и гонки, обзор правым стиком), **меню паузы** по Esc/Start (выход в хаб, настройки, справка), **настройки** (мышь, стик, FOV, громкость, инверсия) — разделы «Управление», «Меню паузы и настройки». В браузере с живым геймпадом не проверялось (только тест без окна) | | ✅ | **Ники** (панель по N, подпись над игроками, в итогах гонки) и **общий рейтинг** лучших времён на сервере (топ-10 на Race/Parkour, таблица по Tab, доски в хабе, таймер и итоги Parkour, сдача в гонке на G) — раздел «Ники и рейтинг» | | ❌ | Текстовый чат — **запланирован**, не сделан (план в «Дорожная карта → Ближайшее») | -| ❌ | Настроек (чувствительность мыши, громкость), меню паузы | +| ✅ | Хаб увеличен (48×32×10 м) и оформлен, в нём играет музыка «как в лифте» — раздел «Карты и порталы», «Лицензии» | | ❌ | Серверных проверок (антиспуфинг позиций, античит): сервер только пересылает | | ❌ | Сохранения прогресса, аккаунтов | | ❌ | Автоматических тестов в CI (есть боты для ручного прогона, см. «Тесты без окна») | @@ -58,7 +58,7 @@ godot --path . # можно запустить несколько ок ## Управление Клавиши привязаны к **физическому расположению** (так работает и на русской раскладке). Действия лежат в -`project.godot → [input]`: `move_forward/back/left/right`, `jump`, `sprint`, `crouch`, `dash`, `ready` (R — «готов» в гонке, перезапуск забега в Parkour), `nick` (N), `leaderboard` (Tab), `giveup` (G). +`project.godot → [input]`: `move_forward/back/left/right`, `jump`, `sprint`, `crouch`, `dash`, `ready` (R — «готов» в гонке, перезапуск забега в Parkour), `nick` (N), `leaderboard` (Tab), `giveup` (G). Действия, которых нет в `project.godot`, добавляет `core/settings.gd` кодом при старте: `pause` (Esc, Start), `zoom` (LT, R3), `look_left/right/up/down` (правый стик) и все кнопки геймпада. | Клавиша | Действие | |---|---| @@ -73,9 +73,25 @@ godot --path . # можно запустить несколько ок | N | ввод/смена ника (панель по центру; Enter — подтвердить, Esc — пропустить/отмена); на время ввода игрок стоит | | Tab | таблица лидеров текущей карты (топ-10 и «Вы: N-й»); в хабе и на картах без рейтинга — вкладки по картам, A/D или стрелки; повторный Tab или Esc закрывают | | ЛКМ (зажать) | зум | -| Esc / клик | отпустить / захватить мышь | +| Esc | **меню паузы** (мышь освобождается); «Продолжить» или повторный Esc — назад в игру и захват мыши | +| клик по игре | захватить мышь | -Бег по стене — в воздухе у стены с зажатым «вперёд» на скорости. Ctrl не используется: в браузере `Ctrl+W` закрывает вкладку. +**Геймпад Xbox** (правый большой палец всегда на стике обзора, поэтому прыжок продублирован на LB, а рывок на RB): + +| Кнопка | Действие | +|---|---| +| Левый стик / правый стик | движение / обзор (кривая 1,6: точно у центра, быстро на краю; скорость — в настройках) | +| A или LB | прыжок (у уступа — подтянуться, у стены — от стены, в воздухе — двойной) | +| RT или L3 | спринт (держать) | +| X или RB | рывок | +| B (держать) | присед; **RT + B на бегу** — подкат | +| LT или R3 | зум | +| Y | «готов» в Race; забег заново в Parkour | +| Back | таблица лидеров (Tab) | +| Крестовина вниз (держать 1,5 с) | сдаться в гонке | +| Start | меню паузы (в меню: крестовина/левый стик, A — выбрать, B — назад) | + +Ник по геймпаду меняется в меню паузы. Бег по стене — в воздухе у стены с зажатым «вперёд» на скорости. Ctrl не используется: в браузере `Ctrl+W` закрывает вкладку. ## Структура проекта @@ -88,6 +104,8 @@ net/ сеть. Net — автолоад: и клиент, и server.tscn пустая сцена для запуска выделенного сервера core/ map_loader.gd автолоад MapLoader: скачивает пакет карты и подключает его (см. «Загрузка карт») + settings.gd автолоад Settings: настройки (user://settings.cfg) и раскладка геймпада в InputMap + pause_menu.gd автолоад PauseMenu: меню паузы Esc/Start (см. «Меню паузы и настройки») overlay.gd автолоад Overlay: панель ника (N) и таблица лидеров (Tab) поверх любой карты lb_view.gd LbView: таблица рейтинга карты (панель Tab, итоги Race/Parkour) и общий стиль панелей player/ @@ -102,7 +120,7 @@ chasers/ rock/ камень: rock.tscn, Rock.glb (+ извлечённая текстура), Guitar.ogg verity/ Верити: verity.tscn, verity2.glb (+ текстура), verity.ogg (звук из видео) maps/ у каждой карты своя папка — она самодостаточна и экспортируется отдельным пакетом - hub/ стартовая комната: hub.tscn, hub.gd (порталы строятся из списка PORTALS) + hub/ стартовая комната: hub.tscn, hub.gd (порталы из списка PORTALS, музыка), hub_decor.gd (оформление), audio/music.ogg + CREDITS.md sandbox/ песочница: sandbox.tscn, forest.gd, birds.gd, levitate.gd, nav_bake.gd, floor/ sky/ audio/ models/ (здания, башня, обелиск, деревья, Эмпайр-стейт…) race/ race.tscn, race.gd, race_rules.gd (правила гонки, сдача, итоги, рейтинг — **общие с Race 2**), race_data.gd (генерируется), baked.gdshader, race.glb, sky.png, audio/music.ogg @@ -130,7 +148,7 @@ tools/ export_all.sh, serve_web.py, deploy_server.sh, test/ ( | Карта | Сцена | Пакет | Что там | |---|---|---|---| -| Хаб | `maps/hub/hub.tscn` | в основном пакете | Комната 28×18×7 м, строится кодом (пол-грид, стены, свет). Порталы в ряд по центру, влезает 6. Над порталами карт «на время» — доски с топ-3 (см. «Ники и рейтинг»). Безопасна, преследователей нет | +| Хаб | `maps/hub/hub.tscn` | в основном пакете | «Лобби»: зал 48×32×10 м, строится кодом (`hub.gd` — стены, порталы, доски, музыка; `hub_decor.gd` — оформление). Порталы в ряд у северной стены с шагом 6 м (влезает 7; SANDBOX `x=-12`, RACE 2 `x=12`, `z=-15`), игрок появляется на `(0, 1.2, 11)`. В центре платформа с вращающейся голограммой, пол с латунными кольцами и дорожкой к порталам, 6 колонн с неоновыми прорезями, потолочные светильники (свечение — `glow`), в боковых стенах шесть окон с ночным городом (шейдер), лавки (на них можно запрыгнуть), пылинки в воздухе, табло «ЛОББИ» на южной стене. Над порталами карт «на время» — доски с топ-3 (см. «Ники и рейтинг»). Музыка `maps/hub/audio/music.ogg` («Local Forecast - Elevator», Kevin MacLeod, CC-BY) играет по кругу, плавно нарастая за 3 с, −9 дБ. Безопасна, преследователей нет | | Песочница | `maps/sandbox/sandbox.tscn` | `world.pck` | Киберпанк-мир: пол-шахматка, здания, 60 деревьев (MultiMesh), птицы, парящий обелиск, звук `tron_01`. Преследователи: **камень** и **Верити**. Портал «HUB» возвращает в хаб | | Backrooms | `maps/backrooms/backrooms.tscn` | `backrooms.pck` | Модель «Backrooms VR» (×1,4). Преследователь: **Верити** (масштаб 0,5). Выход «EXIT» в дальнем конце ведёт в хаб. Гул ламп генерируется кодом | | Parkour | `maps/parkour/parkour.tscn` | `parkour.pck` (7,4 МБ: небо 3,5 + модель 3,4 + музыка 1,1) | Мрачная антиутопия в духе Ghostrunner: дождь, дымный воздух, грязный мокрый бетон, натриевый оранжевый, тревожный красный и больной зелёный свет, надписи-приказы (COMPLY, CURFEW 22:00, RESTRICTED). **Забег на время** (старт — линия на краю стартовой платформы, финиш — вершина; рейтинг на сервере; R — заново). Подъём по мегабашне, ~700 м, 9 чекпоинтов (светящиеся ворота), без преследователей. Секции: крыши (одинарный/двойной прыжок, подтягивание) → каньон из четырёх стен для бега → башня из 9 уступов (двойной прыжок + подтягивание) → лазерный зал (подкат, прыжок, тайминг, рывок) → пропасть из 6 контейнеров (зазоры 16–24 м) → спираль вокруг светящегося ядра (+24 м) → вершина с порталом в хаб. Срыв ниже последнего чекпоинта на 32 м или касание лазера = «СОРВАЛСЯ!» и возрождение на чекпоинте. Музыка: `maps/parkour/audio/music.ogg` (Ogg Vorbis, моно 32 кГц, ~1 МБ; делается `ffmpeg -i исходник.mp3 -ac 1 -ar 32000 -c:a libvorbis -q:a 0 music.ogg`), играет по кругу | @@ -263,6 +281,16 @@ const MAX_RECORD_TIME := 3600.0 - `parkour_run_test.py` — забег: линия, таймер, смерть, финиш, отказ слишком быстрому времени, R, запись в рейтинге. - `ui_tour.tscn --mode=hub|race|parkour` — кадры (`--write-movie`; рейтинг подставлен, сервер не нужен; добавляйте `--server-url=ws://127.0.0.1:1`, иначе окно подключится к реальному серверу и затрёт подставленное). +## Меню паузы и настройки + +`core/pause_menu.gd` (автолоад `PauseMenu`) + `core/settings.gd` (автолоад `Settings`, объявлен раньше `Overlay`). Меню открывается по **Esc / Start** (действие `pause`), игра при этом идёт дальше (мультиплеер), а игрок стоит: `Net.input_blocked = true`, как при вводе ника; игровые клавиши за меню не работают (меню забирает событие раньше `Overlay`). Закрывается кнопкой «Продолжить», повторным Esc/Start или B; мышь снова захватывается. +- **Главная:** Продолжить · **Выйти в хаб** (только если карта не хаб: `MapLoader.enter("hub", …)`, хаб в основном пакете) · Таблица лидеров · Сменить ник · Настройки · Управление. +- **Настройки** (`user://settings.cfg`, пишется при закрытии меню; кнопка «По умолчанию»): чувствительность мыши ×0,2–3 (к `player.MOUSE_SENSITIVITY`), чувствительность стика ×0,3–2,5 (`Settings.STICK_LOOK_SPEED` = 3,6 рад/с), поле зрения 60–100° (`player.base_fov` берётся из `Settings.fov` каждый кадр), громкость (шина Master), инверсия вертикали (мышь и стик). Слайдеры и кнопки управляются геймпадом через стандартные `ui_*`. +- **Управление:** таблица «клавиатура / геймпад». +- **Esc в браузере:** при захваченной мыши браузер сам снимает захват и событие Esc до игры **не доходит**. Поэтому `PauseMenu._process` открывает меню и по факту «мышь была захвачена ≥ 0,5 с и перестала быть» (если не открыт ввод ника и не идёт загрузка карты); после закрытия 0,6 с это не срабатывает (браузер мог отказать в повторном захвате без клика — тогда клик по игре захватит мышь, как раньше). **В живом браузере не проверялось.** +- Геймпад: обзор правым стиком — `player._process` (кадр за кадром, не в физике), мышь — `player._mouse_look`. Движение стиком аналоговое по направлению, но скорость по-прежнему «шаг/спринт» (`direction` нормализуется), как и раньше с клавиатурой. +- Проверка: `godot --headless --path . res://tools/test/pause_test.tscn -- --client --server-url=ws://127.0.0.1:1` — раскладка геймпада в InputMap, открытие/закрытие, блокировка, чувствительность и инверсия, FOV, обзор стиком, A и RT, сохранение, «Выйти в хаб». Кадры меню и хаба: `hub_tour.tscn` (`--menu=main|settings|controls`). + ## Преследователи `chasers/chaser.gd` — один скрипт для всех. Сцены (`rock.tscn`, `verity.tscn`) — это просто модель + звук + `NavigationAgent3D`. @@ -314,7 +342,7 @@ const MAX_RECORD_TIME := 3600.0 ## Загрузка карт пакетами (веб) -Основной пакет игры (`index.pck`) — только хаб, игрок, сеть и порталы (около 70 КБ). Движок — `index.wasm` (39,5 МБ, ~10 МБ с gzip). +Основной пакет игры (`index.pck`) — только хаб (с музыкой), игрок, сеть, меню и порталы (около 1,6 МБ, из них 1,2 МБ — музыка хаба). Движок — `index.wasm` (39,5 МБ, ~10 МБ с gzip). Карты — отдельные `.pck`: `world.pck` (13,6 МБ), `backrooms.pck` (21,3 МБ). Портал вызывает `MapLoader.enter(map, scene, pack, title)`: 1. если сцена уже есть в проекте (редактор, десктоп, пакет уже скачан) — переход сразу; @@ -359,15 +387,16 @@ python3 tools/serve_web.py ../grid-world 8080 # nginx-подобно: COOP/C godot --headless --path /tmp/ts & # сервер godot --headless --path /tmp/tp res://maps/sandbox/sandbox.tscn -- --client --name=A & godot --headless --path /tmp/tp res://maps/sandbox/sandbox.tscn -- --client --name=B -# цепочка карт на реальных пакетах (портал SANDBOX в хабе на (-2.25, -7.5), BACKROOMS на (2.25, -7.5)): +# цепочка карт на реальных пакетах (порталы хаба в ряд на z=-15 с шагом 6 м: SANDBOX x=-12, BACKROOMS x=-6; в `--tp` ниже — SANDBOX): /tmp/tp/tools/export_all.sh /tmp/site && (cd /tmp/site && python3 -m http.server 8777 --bind 127.0.0.1 &) -godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:-2.25,1,-7.5 +godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:-12,1,-15 ``` Для картинок: `godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/a.png --quit-after 25 res://maps/hub/hub.tscn` (окно мелькнёт; кадры — `a00000020.png`). В браузере: Chrome (flatpak `com.google.Chrome`) `--headless=new --enable-logging=stderr --v=0 http://127.0.0.1:8080` — вывод Godot виден в строках `CONSOLE`. **Готовые проверки** (`tools/test/`, запускаются как сцены: в режиме `--script` автозагрузки недоступны при компиляции, поэтому только `res://…tscn`; `-- --client`, иначе headless поднимет сервер на порту 9080): - Race 2: `MAP=race2 tools/test/race_game_test.sh` (правила, см. «Карта Race 2»); боты проходимости — `Blender models/race_02/tests/` (`run.sh`, `run_all.sh`, `compare.sh`, `frames.sh`); пакет через портал хаба — `tools/test/race2_check.txt`. +- `pad_menu_test.tscn` — геймпад в меню (фокус, крестовина, A); `pause_test.tscn` — меню паузы, настройки, геймпад (см. «Меню паузы и настройки»); `hub_tour.tscn` — кадры хаба и меню паузы. - `measure_movement.tscn` — замеры мувсета (прыжки, рывок, стена); `mantle_test.tscn` — подтягивание на 40 вариантах уступов; `parkour_gaps.tscn` — проходимость контейнеров; `parkour_tour.tscn` — облёт карты со снимками (см. заголовок файла). - **Проверяй по существу, а не по имени сцены.** Для карт из пакетов: собери тестовый проект (`make_test_project.sh`), в его `bot.gd` добавь печать фактов (есть ли у `current_scene` скрипт, подменён ли материал, есть ли `PanoramaSkyMaterial`, играет ли музыка, сколько лазеров) и пройди портал с `--pack-url`. Готовый код проверки — `tools/test/parkour_check.txt` (ловит пропавшие `sky.png`/шейдер/музыку/логику). - Ввод ботом: `Input.action_press("jump")` работает для зажатия и `is_action_pressed`; рывок и подкат ловятся в `_unhandled_input`, поэтому для них нужно `Input.parse_input_event(InputEventAction)` с `pressed=true`. @@ -520,6 +549,8 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок ## Журнал изменений (окт 2026) +Последнее (31 окт 2026): геймпад Xbox, меню паузы с выходом в хаб и настройками, увеличенный и оформленный хаб с музыкой. Подробности — «Управление», «Меню паузы и настройки», «Карты и порталы». Выкладка сервера **не нужна** (`net/*` не менялся). + Сделано одной серией работ (хронологически): 1. **Персонаж:** модель киберниндзя со скелетом (752 треугольника, 14 анимаций, IK ног) вместо капсул; цвет свечения от id игрока; свой персонаж спрятан от своей камеры. Сальто камеры при двойном прыжке делалось и убрано по просьбе. 2. **Подтягивание на уступы** починено (не работало от ~2 м на всех картах), замеры мувсета и тестовые сцены в `tools/test/`. @@ -536,4 +567,5 @@ ssh root@192.168.10.110 'journalctl -u my-world -f' # лог: «+ игрок - Модель карты Backrooms — «Backrooms VR», автор VTX (https://sketchfab.com/VTX_car), **CC-BY 4.0** (`maps/backrooms/model/CREDITS.md`). При публикации нужно указывать автора. +- Музыка хаба — «Local Forecast - Elevator», Kevin MacLeod (https://incompetech.com), **CC-BY 4.0** (`maps/hub/audio/CREDITS.md`). При публикации нужно указывать автора. - Остальные модели и звуки — материалы владельца проекта. diff --git a/core/pause_menu.gd b/core/pause_menu.gd new file mode 100644 index 0000000..6f55d3b --- /dev/null +++ b/core/pause_menu.gd @@ -0,0 +1,331 @@ +extends Node +# Автолоад «PauseMenu»: меню паузы по Esc / Start. Игра идёт дальше (это мультиплеер), но игрок стоит +# (Net.input_blocked, как при вводе ника). Страницы: главная, настройки, управление. +# +# В браузере Esc при захваченной мыши не доходит до игры — браузер просто снимает захват. Поэтому меню +# открывается и по самому факту «мышь была захвачена и перестала быть» (см. _process). + +const TEXT := Color(0.92, 0.88, 0.8) +const DIM := Color(0.62, 0.6, 0.56) +const MINE := Color(0.96, 0.76, 0.44) +const HUB_SCENE := "res://maps/hub/hub.tscn" +const REOPEN_GUARD := 0.6 # после закрытия столько секунд снятие захвата не считается нажатием Esc +const CAPTURED_MIN := 0.5 # мышь должна быть захвачена хотя бы столько, чтобы потеря захвата считалась Esc + +var open := false + +var _layer: CanvasLayer +var _pages := {} # имя -> Control +var _page := "main" +var _title: Label +var _subtitle: Label +var _hub_btn: Button +var _lb_btn: Button +var _captured_time := 0.0 +var _guard := 0.0 +var _sliders := {} # имя настройки -> {slider, value} +var _invert: CheckButton + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + _build() + _layer.visible = false + +# ---------- открытие / закрытие ---------- + +func open_menu() -> void: + if open: + return + open = true + Net.input_blocked = true + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _show_page("main") + _layer.visible = true + +func close_menu() -> void: + if not open: + return + open = false + _guard = REOPEN_GUARD + Settings.save() + _layer.visible = false + get_viewport().gui_release_focus() + if not Overlay.nick_open: + Net.input_blocked = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +func _process(delta: float) -> void: + _guard = maxf(_guard - delta, 0.0) + if open: + _captured_time = 0.0 + return + if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + _captured_time += delta + return + # Захват пропал. Если до этого мышь побыла захвачена и мы сами её не отпускали — это Esc в браузере. + var was := _captured_time >= CAPTURED_MIN + _captured_time = 0.0 + if was and _guard <= 0.0 and _can_open(): + open_menu() + +func _can_open() -> bool: + return not Overlay.nick_open and not MapLoader._busy and get_tree().current_scene != null + +func _unhandled_input(event: InputEvent) -> void: + if open: + if event.is_action_pressed("pause") or event.is_action_pressed("ui_cancel"): + if _page == "main": + close_menu() + else: + _show_page("main") + # Игровые клавиши (R, N, Tab, G…) за меню не работают + if not (event is InputEventMouseMotion): + get_viewport().set_input_as_handled() + return + if event.is_action_pressed("pause") and not event.is_echo(): + if Overlay.nick_open or Overlay.lb_open or not _can_open(): + return # Esc закрывает панель ника / таблицу — это делает Overlay + open_menu() + get_viewport().set_input_as_handled() + +# ---------- страницы ---------- + +func _show_page(name: String) -> void: + _page = name + for k in _pages: + _pages[k].visible = (k == name) + match name: + "main": + _title.text = "ПАУЗА" + var map_title: String = Net.MAP_TITLES.get(Net.current_map, "") + _subtitle.text = "Карта: %s" % map_title if map_title != "" else "" + _hub_btn.visible = Net.current_map != "hub" + _lb_btn.visible = true + _focus_first.call_deferred(_pages.main) + "settings": + _title.text = "НАСТРОЙКИ" + _subtitle.text = "Сохраняются автоматически" + _refresh_settings() + _focus_first.call_deferred(_pages.settings) + "controls": + _title.text = "УПРАВЛЕНИЕ" + _subtitle.text = "" + _focus_first.call_deferred(_pages.controls) + +func _focus_first(page: Control) -> void: + for b in page.find_children("*", "Button", true, false): + if b.visible and not b.disabled: + b.grab_focus() + return + +func _build() -> void: + _layer = CanvasLayer.new() + _layer.layer = 30 + add_child(_layer) + + var theme := _make_theme() + var root := Control.new() + root.theme = theme + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _layer.add_child(root) + + var dim := ColorRect.new() + dim.color = Color(0.0, 0.0, 0.0, 0.62) + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(dim) + + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", LbView.panel_style(28)) + root.add_child(panel) + panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + panel.grow_horizontal = Control.GROW_DIRECTION_BOTH + panel.grow_vertical = Control.GROW_DIRECTION_BOTH + + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 12) + box.custom_minimum_size = Vector2(460, 0) + panel.add_child(box) + + _title = LbView.make_label("ПАУЗА", 34, TEXT) + _title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(_title) + _subtitle = LbView.make_label("", 16, DIM) + _subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(_subtitle) + box.add_child(HSeparator.new()) + + _pages["main"] = _build_main() + _pages["settings"] = _build_settings() + _pages["controls"] = _build_controls() + for k in _pages: + box.add_child(_pages[k]) + + var hint := LbView.make_label("Esc / Start — назад", 14, DIM) + hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(hint) + +func _make_theme() -> Theme: + var t := Theme.new() + t.default_font_size = 20 + var normal := _btn_style(Color(0.07, 0.075, 0.08, 0.95), Color(0.30, 0.22, 0.14)) + var hover := _btn_style(Color(0.13, 0.11, 0.09, 0.98), Color(0.75, 0.52, 0.26)) + var pressed := _btn_style(Color(0.20, 0.15, 0.10, 1.0), MINE) + var focus := _btn_style(Color(0.13, 0.11, 0.09, 0.98), MINE) + for cls in ["Button", "CheckButton"]: + t.set_stylebox("normal", cls, normal) + t.set_stylebox("hover", cls, hover) + t.set_stylebox("pressed", cls, pressed) + t.set_stylebox("focus", cls, focus) + t.set_color("font_color", cls, TEXT) + t.set_color("font_hover_color", cls, Color.WHITE) + t.set_color("font_focus_color", cls, Color.WHITE) + t.set_color("font_pressed_color", cls, MINE) + t.set_font_size("font_size", cls, 20) + t.set_stylebox("disabled", "Button", normal) + # Слайдер: тёмная полоса и латунный заполнитель + var track := StyleBoxFlat.new() + track.bg_color = Color(0.1, 0.1, 0.11) + track.set_corner_radius_all(3) + track.content_margin_top = 4 + track.content_margin_bottom = 4 + var fill := StyleBoxFlat.new() + fill.bg_color = Color(0.62, 0.42, 0.2) + fill.set_corner_radius_all(3) + t.set_stylebox("slider", "HSlider", track) + t.set_stylebox("grabber_area", "HSlider", fill) + t.set_stylebox("grabber_area_highlight", "HSlider", fill) + return t + +func _btn_style(bg: Color, border: Color) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg + sb.border_color = border + sb.set_border_width_all(2) + sb.content_margin_left = 16 + sb.content_margin_right = 16 + sb.content_margin_top = 9 + sb.content_margin_bottom = 9 + return sb + +func _button(text: String, cb: Callable) -> Button: + var b := Button.new() + b.text = text + b.focus_mode = Control.FOCUS_ALL + b.pressed.connect(cb) + return b + +func _vbox() -> VBoxContainer: + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 10) + return v + +func _build_main() -> Control: + var v := _vbox() + v.add_child(_button("Продолжить", close_menu)) + _hub_btn = _button("Выйти в хаб", _go_hub) + v.add_child(_hub_btn) + _lb_btn = _button("Таблица лидеров", _open_lb) + v.add_child(_lb_btn) + v.add_child(_button("Сменить ник", _open_nick)) + v.add_child(_button("Настройки", func(): _show_page("settings"))) + v.add_child(_button("Управление", func(): _show_page("controls"))) + return v + +func _go_hub() -> void: + close_menu() + MapLoader.enter("hub", HUB_SCENE, "", "Хаб") + +func _open_lb() -> void: + close_menu() + Overlay.open_lb() + +func _open_nick() -> void: + open = false + _guard = REOPEN_GUARD + _layer.visible = false + get_viewport().gui_release_focus() + Overlay.open_nick(false) # сама держит Net.input_blocked и мышь + +# ---------- настройки ---------- + +func _build_settings() -> Control: + var v := _vbox() + _add_slider(v, "mouse", "Чувствительность мыши", 0.2, 3.0, 0.05, func(x): Settings.mouse_sens = x, func(x): return "×%.2f" % x) + _add_slider(v, "stick", "Чувствительность стика", 0.3, 2.5, 0.05, func(x): Settings.stick_sens = x, func(x): return "×%.2f" % x) + _add_slider(v, "fov", "Поле зрения", 60.0, 100.0, 1.0, func(x): Settings.fov = x, func(x): return "%d°" % int(x)) + _add_slider(v, "volume", "Громкость", 0.0, 1.0, 0.01, func(x): Settings.volume = x, func(x): return "%d%%" % int(round(x * 100.0))) + _invert = CheckButton.new() + _invert.text = "Инвертировать вертикаль" + _invert.focus_mode = Control.FOCUS_ALL + _invert.toggled.connect(func(on: bool): Settings.invert_y = on) + v.add_child(_invert) + v.add_child(_button("По умолчанию", func(): + Settings.reset_defaults() + _refresh_settings())) + v.add_child(_button("Назад", func(): _show_page("main"))) + return v + +func _add_slider(parent: Control, key: String, caption: String, lo: float, hi: float, step: float, setter: Callable, fmt: Callable) -> void: + var row := VBoxContainer.new() + row.add_theme_constant_override("separation", 2) + var head := HBoxContainer.new() + var name_l := LbView.make_label(caption, 18, TEXT) + name_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL + head.add_child(name_l) + var val_l := LbView.make_label("", 18, MINE) + head.add_child(val_l) + row.add_child(head) + var s := HSlider.new() + s.min_value = lo + s.max_value = hi + s.step = step + s.focus_mode = Control.FOCUS_ALL + s.custom_minimum_size = Vector2(0, 26) + s.value_changed.connect(func(x: float): + setter.call(x) + val_l.text = fmt.call(x) + Settings.apply()) + row.add_child(s) + parent.add_child(row) + _sliders[key] = {"slider": s, "label": val_l, "fmt": fmt} + +func _refresh_settings() -> void: + var values := {"mouse": Settings.mouse_sens, "stick": Settings.stick_sens, "fov": Settings.fov, "volume": Settings.volume} + for k in values: + var e: Dictionary = _sliders[k] + e.slider.set_value_no_signal(values[k]) + e.label.text = e.fmt.call(values[k]) + _invert.set_pressed_no_signal(Settings.invert_y) + +# ---------- справка по управлению ---------- + +func _build_controls() -> Control: + var v := _vbox() + var grid := GridContainer.new() + grid.columns = 3 + grid.add_theme_constant_override("h_separation", 22) + grid.add_theme_constant_override("v_separation", 5) + var rows := [ + ["", "Клавиатура", "Геймпад Xbox"], + ["Движение / обзор", "WASD / мышь", "левый / правый стик"], + ["Прыжок, подтягивание", "Пробел", "A или LB"], + ["Спринт", "Shift", "RT или L3"], + ["Рывок", "Q", "X или RB"], + ["Присед (держать)", "C", "B"], + ["Подкат (на бегу)", "Shift + C", "RT + B"], + ["Зум", "ЛКМ", "LT"], + ["«Готов» / забег заново", "R", "Y"], + ["Сдаться в гонке (держать)", "G", "крестовина вниз"], + ["Таблица лидеров", "Tab", "Back"], + ["Ник", "N", "в меню паузы"], + ["Пауза", "Esc", "Start"], + ] + for r in rows.size(): + for c in 3: + var l := LbView.make_label(rows[r][c], 16, MINE if r == 0 else (TEXT if c == 0 else DIM)) + grid.add_child(l) + v.add_child(grid) + var tip := LbView.make_label("Стены: прыжок в воздухе у стены с зажатым «вперёд» — бег по стене.", 14, DIM) + v.add_child(tip) + v.add_child(_button("Назад", func(): _show_page("main"))) + return v diff --git a/core/pause_menu.gd.uid b/core/pause_menu.gd.uid new file mode 100644 index 0000000..ff47c8e --- /dev/null +++ b/core/pause_menu.gd.uid @@ -0,0 +1 @@ +uid://cyvtp5hwd4h5o diff --git a/core/settings.gd b/core/settings.gd new file mode 100644 index 0000000..4b7b619 --- /dev/null +++ b/core/settings.gd @@ -0,0 +1,147 @@ +extends Node +# Автолоад «Settings»: пользовательские настройки (мышь, стик, FOV, громкость) и раскладка геймпада Xbox. +# Геймпад добавляется в InputMap кодом (а не в project.godot): раскладка собрана в одном месте и видна целиком. +# Сохраняется в user://settings.cfg, но только когда игрок что-то менял (save()). +# +# Раскладка под паркур и гонки (правый большой палец всегда остаётся на стике обзора): +# левый стик — движение, правый стик — обзор +# A / LB — прыжок (подтягивание, прыжок от стены, двойной прыжок) RT / L3 — спринт (держать) +# X / RB — рывок B — присед (держать); RT + B на бегу — подкат +# LT / R3 — зум Y — «готов» (гонка) / забег заново (Parkour) +# Back — таблица лидеров крестовина вниз (держать) — сдаться в гонке +# Start — пауза + +signal changed + +const PATH := "user://settings.cfg" + +const DEFAULT_FOV := 60.0 +const STICK_DEADZONE := 0.22 +const STICK_LOOK_SPEED := 3.6 # рад/с при полном отклонении стика и множителе 1.0 (~205°/с) + +var mouse_sens := 1.0 # множитель к player.MOUSE_SENSITIVITY +var stick_sens := 1.0 # множитель к STICK_LOOK_SPEED +var fov := DEFAULT_FOV # базовое поле зрения +var volume := 1.0 # общая громкость 0..1 +var invert_y := false # инверсия вертикали (мышь и стик) + +var gamepad_used := false # игрок трогал геймпад (для подсказок) + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + _setup_gamepad() + load_settings() + apply() + +func _input(event: InputEvent) -> void: + if event is InputEventJoypadButton or (event is InputEventJoypadMotion and absf(event.axis_value) > 0.5): + gamepad_used = true + elif event is InputEventKey or event is InputEventMouseButton: + gamepad_used = false + +# ---------- сохранение ---------- + +func load_settings() -> void: + var cfg := ConfigFile.new() + if cfg.load(PATH) != OK: + return + mouse_sens = clampf(float(cfg.get_value("input", "mouse_sens", mouse_sens)), 0.2, 3.0) + stick_sens = clampf(float(cfg.get_value("input", "stick_sens", stick_sens)), 0.3, 2.5) + invert_y = bool(cfg.get_value("input", "invert_y", invert_y)) + fov = clampf(float(cfg.get_value("video", "fov", fov)), 60.0, 100.0) + volume = clampf(float(cfg.get_value("audio", "volume", volume)), 0.0, 1.0) + +func save() -> void: + var cfg := ConfigFile.new() + cfg.set_value("input", "mouse_sens", mouse_sens) + cfg.set_value("input", "stick_sens", stick_sens) + cfg.set_value("input", "invert_y", invert_y) + cfg.set_value("video", "fov", fov) + cfg.set_value("audio", "volume", volume) + cfg.save(PATH) + +func apply() -> void: + var bus := AudioServer.get_bus_index("Master") + AudioServer.set_bus_mute(bus, volume <= 0.001) + AudioServer.set_bus_volume_db(bus, linear_to_db(maxf(volume, 0.001))) + changed.emit() + +func reset_defaults() -> void: + mouse_sens = 1.0 + stick_sens = 1.0 + fov = DEFAULT_FOV + volume = 1.0 + invert_y = false + apply() + +# ---------- геймпад ---------- + +func _setup_gamepad() -> void: + # Стики: двигаем и смотрим + _axis("move_left", JOY_AXIS_LEFT_X, -1.0) + _axis("move_right", JOY_AXIS_LEFT_X, 1.0) + _axis("move_forward", JOY_AXIS_LEFT_Y, -1.0) + _axis("move_back", JOY_AXIS_LEFT_Y, 1.0) + for a in ["move_left", "move_right", "move_forward", "move_back"]: + InputMap.action_set_deadzone(a, STICK_DEADZONE) + _new_action("look_left", STICK_DEADZONE) + _new_action("look_right", STICK_DEADZONE) + _new_action("look_up", STICK_DEADZONE) + _new_action("look_down", STICK_DEADZONE) + _axis("look_left", JOY_AXIS_RIGHT_X, -1.0) + _axis("look_right", JOY_AXIS_RIGHT_X, 1.0) + _axis("look_up", JOY_AXIS_RIGHT_Y, -1.0) + _axis("look_down", JOY_AXIS_RIGHT_Y, 1.0) + + # Действия + _button("jump", JOY_BUTTON_A) + _button("jump", JOY_BUTTON_LEFT_SHOULDER) # прыжок на плече: правый палец не уходит со стика обзора + _axis("sprint", JOY_AXIS_TRIGGER_RIGHT, 1.0) + _button("sprint", JOY_BUTTON_LEFT_STICK) + _button("crouch", JOY_BUTTON_B) + _button("dash", JOY_BUTTON_X) + _button("dash", JOY_BUTTON_RIGHT_SHOULDER) + _new_action("zoom", 0.3) + _axis("zoom", JOY_AXIS_TRIGGER_LEFT, 1.0) + _button("zoom", JOY_BUTTON_RIGHT_STICK) + _button("ready", JOY_BUTTON_Y) + _button("leaderboard", JOY_BUTTON_BACK) + _button("giveup", JOY_BUTTON_DPAD_DOWN) + + # Навигация по меню: A — выбрать, B — назад (в этом проекте у ui_accept/ui_cancel кнопок геймпада нет) + _button_once("ui_accept", JOY_BUTTON_A) + _button_once("ui_cancel", JOY_BUTTON_B) + _button_once("ui_up", JOY_BUTTON_DPAD_UP) + _button_once("ui_down", JOY_BUTTON_DPAD_DOWN) + _button_once("ui_left", JOY_BUTTON_DPAD_LEFT) + _button_once("ui_right", JOY_BUTTON_DPAD_RIGHT) + + # Пауза: Esc и Start + _new_action("pause", 0.5) + var esc := InputEventKey.new() + esc.physical_keycode = KEY_ESCAPE + InputMap.action_add_event("pause", esc) + _button("pause", JOY_BUTTON_START) + +func _new_action(action: String, deadzone: float) -> void: + if not InputMap.has_action(action): + InputMap.add_action(action, deadzone) + +func _axis(action: String, axis: JoyAxis, value: float) -> void: + _new_action(action, 0.5) + var e := InputEventJoypadMotion.new() + e.axis = axis + e.axis_value = value + InputMap.action_add_event(action, e) + +func _button_once(action: String, button: JoyButton) -> void: + for e in InputMap.action_get_events(action): + if e is InputEventJoypadButton and e.button_index == button: + return + _button(action, button) + +func _button(action: String, button: JoyButton) -> void: + _new_action(action, 0.5) + var e := InputEventJoypadButton.new() + e.button_index = button + InputMap.action_add_event(action, e) diff --git a/core/settings.gd.uid b/core/settings.gd.uid new file mode 100644 index 0000000..1b8175e --- /dev/null +++ b/core/settings.gd.uid @@ -0,0 +1 @@ +uid://bkyvk0c3jox51 diff --git a/maps/hub/audio/CREDITS.md b/maps/hub/audio/CREDITS.md new file mode 100644 index 0000000..4cdf7d2 --- /dev/null +++ b/maps/hub/audio/CREDITS.md @@ -0,0 +1,7 @@ +# Музыка хаба + +«Local Forecast - Elevator» — Kevin MacLeod (incompetech.com) +Лицензия: Creative Commons: By Attribution (CC BY) — http://creativecommons.org/licenses/by/4.0/ +Источник: https://incompetech.com/music/royalty-free/mp3-royaltyfree/Local%20Forecast%20-%20Elevator.mp3 +Обработка: моно, 32 кГц, Ogg Vorbis q1 (`ffmpeg -i исходник.mp3 -vn -map_metadata -1 -ac 1 -ar 32000 -c:a libvorbis -q:a 1 music.ogg`). +При публикации игры нужно указывать автора. diff --git a/maps/hub/audio/music.ogg b/maps/hub/audio/music.ogg new file mode 100644 index 0000000..7479fd5 Binary files /dev/null and b/maps/hub/audio/music.ogg differ diff --git a/maps/hub/audio/music.ogg.import b/maps/hub/audio/music.ogg.import new file mode 100644 index 0000000..904fdb9 --- /dev/null +++ b/maps/hub/audio/music.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://ch6afg8c4xjr4" +path="res://.godot/imported/music.ogg-07da3df5f1653a19addb3d04617388e7.oggvorbisstr" + +[deps] + +source_file="res://maps/hub/audio/music.ogg" +dest_files=["res://.godot/imported/music.ogg-07da3df5f1653a19addb3d04617388e7.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/maps/hub/hub.gd b/maps/hub/hub.gd index c2f2a41..2742f96 100644 --- a/maps/hub/hub.gd +++ b/maps/hub/hub.gd @@ -1,7 +1,8 @@ extends Node3D # Стартовая комната: ничего тяжёлого, только пол, стены и порталы на карты. Сами карты качаются # отдельными пакетами при входе в портал (World/map_loader.gd) — поэтому игра стартует быстро. -# Чтобы добавить карту — допиши запись в PORTALS: порталы встанут в ряд по центру (влезает 6). +# Чтобы добавить карту — допиши запись в PORTALS: порталы встанут в ряд по центру (влезает 7). +# Оформление (колонны, окна, голограмма, лавки) — hub_decor.gd; музыка «как в лифте» играет по кругу. const PORTALS := [ {"title": "SANDBOX", "map": "world", "scene": "res://maps/sandbox/sandbox.tscn", "pack": "world.pck", "color": Color(0.2, 0.9, 1.0)}, @@ -11,11 +12,15 @@ const PORTALS := [ {"title": "RACE 2", "map": "race2", "scene": "res://maps/race2/race2.tscn", "pack": "race2.pck", "color": Color(0.45, 0.85, 0.35)}, ] -const ROOM_WIDTH := 28.0 -const ROOM_DEPTH := 18.0 -const ROOM_HEIGHT := 7.0 # +1 м: над порталами доски рейтинга, над ними — «CHOOSE A MAP» +const ROOM_WIDTH := 48.0 +const ROOM_DEPTH := 32.0 +const ROOM_HEIGHT := 10.0 # над порталами доски рейтинга, над ними — «CHOOSE A MAP» const WALL := 0.5 -const PORTAL_SPACING := 4.5 +const PORTAL_SPACING := 6.0 + +const MUSIC_PATH := "res://maps/hub/audio/music.ogg" +const MUSIC_DB := -9.0 +const DecorScript := preload("res://maps/hub/hub_decor.gd") const GRID_SHADER := """ shader_type spatial; @@ -37,6 +42,8 @@ func _ready() -> void: _build_portals() _build_environment() _build_boards() + _build_decor() + _start_music() if not Net.nick_asked: Overlay.open_nick.call_deferred(true) # первый запуск: спрашиваем ник @@ -45,11 +52,14 @@ func _build_room() -> void: body.name = "Room" add_child(body) - var floor_mat := ShaderMaterial.new() - floor_mat.shader = Shader.new() - floor_mat.shader.code = GRID_SHADER + var decor := DecorScript.new() + decor.name = "Decor" + add_child(decor) + var floor_mat := decor.floor_material() var wall_mat := StandardMaterial3D.new() - wall_mat.albedo_color = Color(0.09, 0.1, 0.14) + wall_mat.albedo_color = Color(0.12, 0.13, 0.18) + wall_mat.metallic = 0.3 + wall_mat.roughness = 0.6 wall_mat.roughness = 1.0 var ceiling_mat := StandardMaterial3D.new() ceiling_mat.albedo_color = Color(0.05, 0.055, 0.08) @@ -102,7 +112,8 @@ func _build_portals() -> void: label.font_size = 64 label.outline_size = 12 label.modulate = Color(0.7, 0.9, 1.0) - label.position = Vector3(0, ROOM_HEIGHT - 0.35, -ROOM_DEPTH * 0.5 + 0.1) + label.position = Vector3(0, 7.2, -ROOM_DEPTH * 0.5 + 0.1) + label.pixel_size = 0.016 add_child(label) # ---------- доски рейтинга над порталами карт «на время» ---------- @@ -206,15 +217,38 @@ func _build_environment() -> void: env.background_color = Color(0.02, 0.025, 0.04) env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color(0.5, 0.58, 0.75) - env.ambient_light_energy = 0.55 + env.ambient_light_energy = 1.0 + env.glow_enabled = true # неон и светильники светятся (в Compatibility glow работает) + env.glow_intensity = 0.8 + env.glow_bloom = 0.1 var we := WorldEnvironment.new() we.environment = env add_child(we) var lamp := OmniLight3D.new() - lamp.position = Vector3(0, ROOM_HEIGHT - 0.6, 2.0) - lamp.omni_range = 22.0 - lamp.light_energy = 0.8 + lamp.position = Vector3(0, ROOM_HEIGHT - 0.8, -6.0) + lamp.omni_range = 30.0 + lamp.light_energy = 0.9 lamp.light_color = Color(0.8, 0.9, 1.0) lamp.shadow_enabled = false add_child(lamp) + +# ---------- оформление и музыка ---------- + +func _build_decor() -> void: + var decor := get_node("Decor") + decor.build(ROOM_WIDTH, ROOM_DEPTH, ROOM_HEIGHT) + +func _start_music() -> void: + var stream = load(MUSIC_PATH) + if stream == null: + return + if stream is AudioStreamOggVorbis: + stream.loop = true + var player := AudioStreamPlayer.new() + player.name = "Music" + player.stream = stream + player.volume_db = -40.0 + player.autoplay = true + add_child(player) + create_tween().tween_property(player, "volume_db", MUSIC_DB, 3.0) # плавно нарастает diff --git a/maps/hub/hub.tscn b/maps/hub/hub.tscn index e4390ca..328ae48 100644 --- a/maps/hub/hub.tscn +++ b/maps/hub/hub.tscn @@ -7,4 +7,4 @@ script = ExtResource("1_hub") [node name="Player" parent="." instance=ExtResource("2_player")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 5) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 11) diff --git a/maps/hub/hub_decor.gd b/maps/hub/hub_decor.gd new file mode 100644 index 0000000..61f269e --- /dev/null +++ b/maps/hub/hub_decor.gd @@ -0,0 +1,356 @@ +extends Node3D +# Оформление хаба: пол с кольцами, колонны, потолочные светильники, окна с ночным городом, лавки, +# платформа с вращающейся голограммой в центре, пылинки в воздухе. Размеры берутся из hub.gd. +# Всё строится кодом и без тяжёлых ресурсов — хаб лежит в основном пакете игры. + +const BRASS := Color(0.85, 0.58, 0.26) +const CYAN := Color(0.25, 0.85, 1.0) +const DARK := Color(0.045, 0.05, 0.065) + +const FLOOR_SHADER := """ +shader_type spatial; +varying vec3 wpos; +void vertex() { wpos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; } +void fragment() { + vec2 cell = wpos.xz / 2.0; + vec2 g = abs(fract(cell - 0.5) - 0.5) / fwidth(cell); + float line = 1.0 - min(min(g.x, g.y), 1.0); + float r = length(wpos.xz); + float ring = 1.0 - smoothstep(0.0, 0.09, min(abs(r - 7.5), abs(r - 12.5))); + // дорожка света от платформы к порталам + float lane = 1.0 - smoothstep(0.0, 0.07, abs(wpos.x)) ; + lane *= step(wpos.z, -4.0) * 0.8; + vec3 base = vec3(0.028, 0.032, 0.045); + vec3 col = mix(base, vec3(0.08, 0.5, 0.65), line * 0.55); + col = mix(col, vec3(0.9, 0.58, 0.24), clamp(ring + lane, 0.0, 1.0)); + ALBEDO = col * 0.55; + EMISSION = vec3(0.03, 0.25, 0.34) * line + vec3(0.5, 0.3, 0.1) * clamp(ring + lane, 0.0, 1.0); + ROUGHNESS = 0.35; + METALLIC = 0.4; +} +""" + +# Окно: ночной город — небо с зарёй у горизонта, силуэты домов со светящимися окнами +const CITY_SHADER := """ +shader_type spatial; +render_mode unshaded; +float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +void fragment() { + float yb = 1.0 - UV.y; + vec3 col = mix(vec3(0.015, 0.02, 0.06), vec3(0.42, 0.14, 0.30), pow(yb, 2.2) * 0.9); + col += vec3(0.5, 0.25, 0.1) * smoothstep(0.35, 0.0, yb) * 0.6; + for (int layer = 0; layer < 2; layer++) { + float n = layer == 0 ? 14.0 : 24.0; + float id = floor(UV.x * n + float(layer) * 7.0); + float top = (layer == 0 ? 0.42 : 0.26) * (0.35 + 0.65 * h(vec2(id, float(layer) + 1.0))); + if (yb < top) { + vec3 body = layer == 0 ? vec3(0.02, 0.02, 0.035) : vec3(0.05, 0.03, 0.07); + vec2 wc = vec2(UV.x * n * 3.0, yb * (layer == 0 ? 34.0 : 22.0)); + vec2 wi = floor(wc); + vec2 wf = fract(wc); + float lit = step(0.62, h(wi + id)) * step(0.2, wf.x) * step(wf.x, 0.8) * step(0.25, wf.y) * step(wf.y, 0.75); + vec3 wcol = h(wi + 3.0) > 0.55 ? vec3(1.0, 0.7, 0.3) : vec3(0.4, 0.8, 1.0); + col = body + wcol * lit * (layer == 0 ? 0.9 : 0.5); + break; + } + } + ALBEDO = col; +} +""" + +var _spin: Array = [] # [узел, ось, скорость] +var _pulse_mat: StandardMaterial3D +var _t := 0.0 + +func build(width: float, depth: float, height: float) -> void: + _build_pillars(width, depth, height) + _build_ceiling(width, depth, height) + _build_windows(width, depth, height) + _build_benches(width, depth) + _build_dais() + _build_signs(width, depth, height) + _build_dust(width, depth, height) + +func floor_material() -> ShaderMaterial: + var m := ShaderMaterial.new() + m.shader = Shader.new() + m.shader.code = FLOOR_SHADER + return m + +func _process(delta: float) -> void: + _t += delta + for s in _spin: + (s[0] as Node3D).rotate(s[1], s[2] * delta) + if _pulse_mat: + _pulse_mat.emission_energy_multiplier = 1.6 + 0.5 * sin(_t * 1.6) + +# ---------- вспомогательное ---------- + +func _emissive(c: Color, energy := 2.0) -> StandardMaterial3D: + var m := StandardMaterial3D.new() + m.albedo_color = c.darkened(0.6) + m.emission_enabled = true + m.emission = c + m.emission_energy_multiplier = energy + return m + +func _matte(c: Color, metallic := 0.0, rough := 0.7) -> StandardMaterial3D: + var m := StandardMaterial3D.new() + m.albedo_color = c + m.metallic = metallic + m.roughness = rough + return m + +func _box(size: Vector3, pos: Vector3, mat: Material, collide := false) -> MeshInstance3D: + var mesh := BoxMesh.new() + mesh.size = size + mesh.material = mat + var mi := MeshInstance3D.new() + mi.mesh = mesh + mi.position = pos + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(mi) + if collide: + var body := StaticBody3D.new() + var cs := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = size + cs.shape = shape + body.add_child(cs) + mi.add_child(body) + return mi + +func _label(text: String, size: float, color: Color, pos: Vector3, yaw := 0.0) -> Label3D: + var l := Label3D.new() + l.text = text + l.font_size = 64 + l.pixel_size = size + l.outline_size = 10 + l.modulate = color + l.shaded = false + l.position = pos + l.rotation.y = yaw + add_child(l) + return l + +# ---------- колонны ---------- + +func _build_pillars(width: float, depth: float, height: float) -> void: + var steel := _matte(Color(0.07, 0.075, 0.09), 0.6, 0.4) + var glow := _emissive(CYAN, 2.2) + var brass := _emissive(BRASS, 1.4) + var px := width * 0.5 - 8.0 + for sx in [-1.0, 1.0]: + for z in [-6.0, 2.0, 10.0]: + var p := Vector3(sx * px, 0.0, z) + _box(Vector3(1.1, height, 1.1), p + Vector3(0, height * 0.5, 0), steel, true) + _box(Vector3(1.2, 0.18, 1.2), p + Vector3(0, 0.09, 0), brass) + _box(Vector3(1.2, 0.18, 1.2), p + Vector3(0, height - 0.09, 0), brass) + # светящаяся прорезь на каждой грани по высоте + _box(Vector3(0.12, height - 1.6, 1.14), p + Vector3(0, height * 0.5, 0), glow) + _box(Vector3(1.14, height - 1.6, 0.12), p + Vector3(0, height * 0.5, 0), glow) + +# ---------- потолок ---------- + +func _build_ceiling(width: float, depth: float, height: float) -> void: + var strip := _emissive(Color(1.0, 0.86, 0.62), 2.4) + var y := height - 0.08 + for x in [-18.0, -9.0, 0.0, 9.0, 18.0]: + _box(Vector3(0.35, 0.12, depth - 4.0), Vector3(x, y, 0), strip) + for z in [-8.0, 0.0, 8.0]: + _box(Vector3(width - 4.0, 0.1, 0.22), Vector3(0, y + 0.02, z), _emissive(BRASS, 1.2)) + # большое кольцо над платформой + var ring := TorusMesh.new() + ring.inner_radius = 3.9 + ring.outer_radius = 4.1 + ring.material = _emissive(CYAN, 2.0) + var mi := MeshInstance3D.new() + mi.mesh = ring + mi.position = Vector3(0, height - 0.5, 0) + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(mi) + # мягкий свет зон: тёплый у порталов, холодный в центре + for x in [-14.0, 14.0]: + var l := OmniLight3D.new() + l.position = Vector3(x, height - 1.0, 4.0) + l.omni_range = 20.0 + l.light_energy = 0.7 + l.light_color = Color(1.0, 0.82, 0.6) + l.shadow_enabled = false + add_child(l) + +# ---------- окна в боковых стенах ---------- + +func _build_windows(width: float, depth: float, height: float) -> void: + var city := ShaderMaterial.new() + city.shader = Shader.new() + city.shader.code = CITY_SHADER + var frame := _matte(Color(0.05, 0.05, 0.06), 0.7, 0.35) + var brass := _emissive(BRASS, 0.9) + var hw := width * 0.5 + var win := Vector2(8.5, 5.6) + var y := 4.6 + for side in [-1, 1]: + var yaw := PI * 0.5 * float(-side) # лицом в комнату + var x := float(side) * (hw - 0.04) + for z in [-8.5, 0.5, 9.5]: + var q := QuadMesh.new() + q.size = win + q.material = city + var mi := MeshInstance3D.new() + mi.mesh = q + mi.position = Vector3(x, y, z) + mi.rotation.y = yaw + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(mi) + # рама и переплёты + var inner_x := float(side) * (hw - 0.12) + _box(Vector3(0.1, win.y, 0.18), Vector3(inner_x, y, z - win.x * 0.25), frame) + _box(Vector3(0.1, win.y, 0.18), Vector3(inner_x, y, z + win.x * 0.25), frame) + _box(Vector3(0.1, 0.18, win.x), Vector3(inner_x, y - 0.5, z), frame) + for e in [-1.0, 1.0]: + _box(Vector3(0.12, win.y + 0.5, 0.2), Vector3(inner_x, y, z + e * (win.x * 0.5 + 0.1)), frame) + var wl := OmniLight3D.new() # отсвет города на полу и лавках + wl.position = Vector3(float(side) * (hw - 3.0), y - 1.0, z) + wl.light_color = Color(0.75, 0.45, 0.9) + wl.light_energy = 1.1 + wl.omni_range = 11.0 + wl.shadow_enabled = false + add_child(wl) + _box(Vector3(0.12, 0.08, win.x + 0.5), Vector3(inner_x, y - win.y * 0.5 - 0.3, z), brass) + _box(Vector3(0.12, 0.08, win.x + 0.5), Vector3(inner_x, y + win.y * 0.5 + 0.3, z), brass) + +# ---------- лавки ---------- + +func _build_benches(width: float, _depth: float) -> void: + var seat := _matte(Color(0.16, 0.08, 0.06), 0.0, 0.55) + var metal := _matte(Color(0.08, 0.08, 0.1), 0.8, 0.3) + var x := width * 0.5 - 2.0 + for side in [-1.0, 1.0]: + for z in [-8.5, 0.5, 9.5]: + var base := Vector3(side * x, 0.0, z) + _box(Vector3(1.0, 0.45, 5.0), base + Vector3(0, 0.225, 0), seat, true) + _box(Vector3(0.25, 0.9, 5.0), base + Vector3(side * 0.5, 0.7, 0), seat) + _box(Vector3(0.95, 0.08, 4.8), base + Vector3(0, 0.02, 0), metal) + _box(Vector3(0.05, 0.04, 4.8), base + Vector3(-side * 0.5, 0.3, 0), _emissive(BRASS, 1.0)) + +# ---------- центр: платформа и голограмма ---------- + +func _build_dais() -> void: + var body := StaticBody3D.new() + add_child(body) + var cyl := CylinderMesh.new() + cyl.top_radius = 3.6 + cyl.bottom_radius = 3.7 + cyl.height = 0.32 + cyl.material = _matte(Color(0.06, 0.065, 0.08), 0.7, 0.3) + var mi := MeshInstance3D.new() + mi.mesh = cyl + mi.position.y = 0.16 + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + body.add_child(mi) + var cs := CollisionShape3D.new() + var shape := CylinderShape3D.new() + shape.radius = 3.65 + shape.height = 0.32 + cs.shape = shape + cs.position.y = 0.16 + body.add_child(cs) + var rim := TorusMesh.new() + rim.inner_radius = 3.45 + rim.outer_radius = 3.6 + rim.material = _emissive(BRASS, 1.8) + var rim_mi := MeshInstance3D.new() + rim_mi.mesh = rim + rim_mi.position.y = 0.33 + rim_mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(rim_mi) + + _pulse_mat = _emissive(CYAN, 2.0) + var holo := Node3D.new() + holo.position = Vector3(0, 2.6, 0) + add_child(holo) + var core := SphereMesh.new() + core.radius = 0.5 + core.height = 1.0 + core.material = _pulse_mat + var core_mi := MeshInstance3D.new() + core_mi.mesh = core + core_mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + holo.add_child(core_mi) + var specs := [[1.1, CYAN, Vector3(1, 0, 0), 0.7], [1.5, BRASS, Vector3(0, 0, 1), -0.5], [1.9, CYAN, Vector3(0, 1, 0), 0.35]] + for i in specs.size(): + var t := TorusMesh.new() + t.inner_radius = specs[i][0] - 0.035 + t.outer_radius = specs[i][0] + 0.035 + t.rings = 48 + t.material = _emissive(specs[i][1], 2.2) + var pivot := Node3D.new() + holo.add_child(pivot) + pivot.rotation = Vector3(0.4 * i, 0.9 * i, 0.0) + var tm := MeshInstance3D.new() + tm.mesh = t + tm.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + pivot.add_child(tm) + _spin.append([pivot, specs[i][2], specs[i][3]]) + var light := OmniLight3D.new() + light.position = Vector3(0, 2.6, 0) + light.light_color = CYAN + light.light_energy = 1.4 + light.omni_range = 11.0 + light.shadow_enabled = false + add_child(light) + var l := Label3D.new() + l.text = "AMAN'S WORLD" + l.billboard = BaseMaterial3D.BILLBOARD_ENABLED + l.font_size = 64 + l.pixel_size = 0.009 + l.outline_size = 12 + l.modulate = Color(1.0, 0.9, 0.7) + l.shaded = false + l.position = Vector3(0, 4.9, 0) + add_child(l) + +# ---------- вывески ---------- + +func _build_signs(width: float, depth: float, height: float) -> void: + var hd := depth * 0.5 + # южная стена: «лифтовая» панель этажа + _box(Vector3(10.0, 2.2, 0.15), Vector3(0, 5.0, hd - 0.08), _matte(Color(0.03, 0.03, 0.04), 0.5, 0.4)) + _box(Vector3(10.3, 0.08, 0.2), Vector3(0, 6.15, hd - 0.1), _emissive(BRASS, 1.2)) + _box(Vector3(10.3, 0.08, 0.2), Vector3(0, 3.85, hd - 0.1), _emissive(BRASS, 1.2)) + _label("ЛОББИ", 0.02, Color(1.0, 0.82, 0.5), Vector3(0, 5.4, hd - 0.2), PI) + _label("ЭТАЖ 0 · ВСЕ КАРТЫ ВНИЗУ ПО КОРИДОРУ", 0.008, Color(0.7, 0.9, 1.0), Vector3(0, 4.4, hd - 0.2), PI) + # северная стена: фриз над порталами + _box(Vector3(width - 6.0, 0.12, 0.3), Vector3(0, 8.9, -hd + 0.15), _emissive(BRASS, 1.3)) + _box(Vector3(width - 6.0, 0.12, 0.3), Vector3(0, 8.0, -hd + 0.15), _emissive(BRASS, 1.3)) + +# ---------- пыль в лучах света ---------- + +func _build_dust(width: float, depth: float, height: float) -> void: + var p := GPUParticles3D.new() + p.amount = 140 + p.lifetime = 14.0 + p.preprocess = 14.0 + p.visibility_aabb = AABB(Vector3(-width * 0.5, 0, -depth * 0.5), Vector3(width, height, depth)) + var pm := ParticleProcessMaterial.new() + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + pm.emission_box_extents = Vector3(width * 0.5 - 1.0, height * 0.5 - 0.5, depth * 0.5 - 1.0) + pm.gravity = Vector3.ZERO + pm.direction = Vector3(0.2, 1.0, 0.1) + pm.spread = 180.0 + pm.initial_velocity_min = 0.05 + pm.initial_velocity_max = 0.25 + p.process_material = pm + var q := QuadMesh.new() + q.size = Vector2(0.05, 0.05) + var m := StandardMaterial3D.new() + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + m.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + m.albedo_color = Color(1.0, 0.9, 0.7, 0.35) + q.material = m + p.draw_pass_1 = q + p.position = Vector3(0, height * 0.5, 0) + p.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(p) diff --git a/maps/hub/hub_decor.gd.uid b/maps/hub/hub_decor.gd.uid new file mode 100644 index 0000000..292f070 --- /dev/null +++ b/maps/hub/hub_decor.gd.uid @@ -0,0 +1 @@ +uid://or5cgrfhp73l diff --git a/player/player.gd b/player/player.gd index f94b57c..3523a42 100644 --- a/player/player.gd +++ b/player/player.gd @@ -207,7 +207,8 @@ func _ready() -> void: add_to_group("local_player") Input.mouse_mode = Input.MOUSE_MODE_CAPTURED head_base_pos = head.position - base_fov = head.fov + base_fov = Settings.fov + head.fov = base_fov # Тело видно в редакторе и отбрасывает тень, но своя камера его не рисует (слой 2) for mi: MeshInstance3D in $Model.find_children("*", "MeshInstance3D", true, false): @@ -245,8 +246,7 @@ func _setup_vignette() -> void: rect.material = vignette_material func _unhandled_input(event: InputEvent) -> void: - if event.is_action_pressed("ui_cancel"): - Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + # Esc / Start теперь открывают меню паузы (core/pause_menu.gd), оно само освобождает мышь if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not Net.input_blocked: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # (панель ника открыта — мышь нужна на экране) @@ -255,10 +255,7 @@ func _unhandled_input(event: InputEvent) -> void: return if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: - var zoom_factor := head.fov / base_fov - rotate_y(-event.relative.x * MOUSE_SENSITIVITY * zoom_factor) - head.rotate_x(-event.relative.y * MOUSE_SENSITIVITY * zoom_factor) - head.rotation.x = clamp(head.rotation.x, deg_to_rad(-80), deg_to_rad(80)) + _mouse_look(event.relative) if input_frozen or Net.input_blocked: return @@ -268,6 +265,32 @@ func _unhandled_input(event: InputEvent) -> void: elif event.is_action_pressed("dash"): _try_dash() +# Обзор мышью: смещение в пикселях -> поворот с учётом чувствительности из настроек и зума +func _mouse_look(relative: Vector2) -> void: + var k := MOUSE_SENSITIVITY * Settings.mouse_sens * head.fov / base_fov + _look(relative.x * k, relative.y * k) + +# Поворот обзора (радианы за событие/кадр); вертикаль можно инвертировать в настройках +func _look(dx: float, dy: float) -> void: + if Settings.invert_y: + dy = -dy + rotate_y(-dx) + head.rotate_x(-dy) + head.rotation.x = clamp(head.rotation.x, deg_to_rad(-80), deg_to_rad(80)) + +# Обзор правым стиком геймпада (плавно, каждый кадр); кривая 1.6 даёт точность у центра и быстрый разворот на краю +func _process(delta: float) -> void: + base_fov = Settings.fov + if is_dead or Net.input_blocked: # открыто меню/панель ника (на отсчёте гонки обзор разрешён, как мышью) + return + var v := Input.get_vector("look_left", "look_right", "look_up", "look_down") + if v == Vector2.ZERO: + return + var strength := pow(minf(v.length(), 1.0), 1.6) + v = v.normalized() * strength + var k := Settings.STICK_LOOK_SPEED * Settings.stick_sens * delta * head.fov / base_fov + _look(v.x * k, v.y * k) + # Битовые флаги состояния для сетевой синхронизации (см. Net.FLAG_*) func get_net_flags() -> int: var flags := 0 @@ -887,8 +910,8 @@ func _update_camera(delta: float, input_dir: Vector2) -> void: head.rotation.z = current_roll + randf_range(-1.0, 1.0) * shake_strength * 0.3 # Зум на ЛКМ - is_zooming = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) \ - and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED + is_zooming = (Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED) \ + or (Input.is_action_pressed("zoom") and not Net.input_blocked) # FOV: зум > рывок > слайд > стена > скорость бега var target_fov := base_fov + sprint_factor * SPRINT_FOV_BONUS diff --git a/project.godot b/project.godot index fc0504c..2410a54 100644 --- a/project.godot +++ b/project.godot @@ -12,19 +12,20 @@ config_version=5 config/name="Aman's World" run/main_scene="res://maps/hub/hub.tscn" -config/features=PackedStringArray("4.7", "GL Compatibility") config/use_custom_user_dir=true config/custom_user_dir_name="world2" -config/icon="res://branding/icon.svg" +config/features=PackedStringArray("4.7", "GL Compatibility") boot_splash/bg_color=Color(0.031, 0.031, 0.039, 1) boot_splash/image="res://branding/splash.png" -boot_splash/fullsize=true +config/icon="res://branding/icon.svg" [autoload] Net="*res://net/net.gd" MapLoader="*res://core/map_loader.gd" +Settings="*res://core/settings.gd" Overlay="*res://core/overlay.gd" +PauseMenu="*res://core/pause_menu.gd" [display] diff --git a/tools/test/bot.gd b/tools/test/bot.gd index 868ad2b..6ff0964 100644 --- a/tools/test/bot.gd +++ b/tools/test/bot.gd @@ -1,7 +1,7 @@ extends Node # Тестовый бот (автозагрузка во ВРЕМЕННОЙ копии проекта, см. tools/test/make_test_project.sh). # Пережидает смену сцен, по расписанию телепортирует игрока и раз в секунду печатает, что видит. -# godot --headless --path <копия> res://maps/hub/hub.tscn -- --client --name=A --tp=2:-2.25,1,-7.5 --tp=9:0,1,5 +# godot --headless --path <копия> res://maps/hub/hub.tscn -- --client --name=A --tp=2:-12,1,-15 --tp=9:0,1,11 # --tp=<секунда>:,, — телепорт игрока на этой секунде (например, прямо в портал). # Остальные аргументы пользователя (--server-url=..., --pack-url=...) читают Net и MapLoader. diff --git a/tools/test/hub_tour.gd b/tools/test/hub_tour.gd new file mode 100644 index 0000000..4d00b3f --- /dev/null +++ b/tools/test/hub_tour.gd @@ -0,0 +1,34 @@ +extends Node +# Облёт хаба для кадров: телепорт игрока по точкам, кадры пишет Godot (--write-movie). +# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/h.png --quit-after 45 res://tools/test/hub_tour.tscn -- --client --server-url=ws://127.0.0.1:1 +# Меню паузы: добавьте `--menu=main|settings|controls` (тогда в кадре меню на хабе, остальной облёт отключён). +# Кадры: h00000005.png и т. д. (каждые 5 кадров — следующая точка из spots). +var level +var p +var frame := 0 +# позиция центра капсулы, рыскание (0 = на север, к порталам), наклон головы (градусы) +var spots := [ + [Vector3(0, 1.25, 12), 0, 4], [Vector3(-10, 1.25, -6), 90, 8], [Vector3(10, 1.25, 8), -90, 6], + [Vector3(0, 1.25, -9), 0, 12], [Vector3(0, 1.25, -5), 180, 10], [Vector3(14, 1.25, -9), 0, 8], + [Vector3(-20, 1.25, 2), -90, 0], [Vector3(0, 6.0, 13), 0, -18], +] +func _ready() -> void: + Net.nick_asked = true # без панели «Введите ник» + level = (load("res://maps/hub/hub.tscn") as PackedScene).instantiate() + add_child(level) + p = level.get_node("Player") + p.set_physics_process(false) +func _process(_d: float) -> void: + frame += 1 + for a in OS.get_cmdline_user_args(): + if a.begins_with("--menu="): + if frame == 3: + PauseMenu.open_menu() + PauseMenu._show_page(a.substr(7)) + return + var i := int(frame / 5) + if i < spots.size() and frame % 5 == 1: + var s = spots[i] + p.global_position = s[0] + p.rotation.y = deg_to_rad(s[1]) + p.head.rotation.x = deg_to_rad(s[2]) diff --git a/tools/test/hub_tour.gd.uid b/tools/test/hub_tour.gd.uid new file mode 100644 index 0000000..a6ba9ad --- /dev/null +++ b/tools/test/hub_tour.gd.uid @@ -0,0 +1 @@ +uid://dndxmq38i6wyg diff --git a/tools/test/hub_tour.tscn b/tools/test/hub_tour.tscn new file mode 100644 index 0000000..71bc8f3 --- /dev/null +++ b/tools/test/hub_tour.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/test/hub_tour.gd" id="1"] + +[node name="T" type="Node"] +script = ExtResource("1") diff --git a/tools/test/pad_menu_test.gd b/tools/test/pad_menu_test.gd new file mode 100644 index 0000000..b6c9c39 --- /dev/null +++ b/tools/test/pad_menu_test.gd @@ -0,0 +1,33 @@ +extends Node +# Геймпад в меню паузы: фокус, A выбирает, крестовина двигает. +func frames(n: int) -> void: + for i in n: + await get_tree().process_frame +func btn(b: JoyButton, pressed: bool) -> void: + var e := InputEventJoypadButton.new() + e.button_index = b + e.pressed = pressed + Input.parse_input_event(e) +func _ready() -> void: + Net.nick_asked = true + add_child((load("res://maps/hub/hub.tscn") as PackedScene).instantiate()) + await frames(5) + PauseMenu.open_menu() + await frames(5) + var f := get_viewport().gui_get_focus_owner() + print("фокус: ", f, " текст: ", f.text if f else "—") + btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2) + f = get_viewport().gui_get_focus_owner() + print("после вниз: ", f.text if f else "—") + btn(JOY_BUTTON_DPAD_UP, true); await frames(2); btn(JOY_BUTTON_DPAD_UP, false); await frames(2) + btn(JOY_BUTTON_A, true); await frames(2); btn(JOY_BUTTON_A, false); await frames(3) + print("после A на «Продолжить»: меню открыто = ", PauseMenu.open) + PauseMenu.open_menu(); await frames(5) + btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2) + btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2) + btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2) + f = get_viewport().gui_get_focus_owner() + print("фокус перед A: ", f.text if f else "—") + btn(JOY_BUTTON_A, true); await frames(2); btn(JOY_BUTTON_A, false); await frames(3) + print("страница после A: ", PauseMenu._page) + get_tree().quit() diff --git a/tools/test/pad_menu_test.gd.uid b/tools/test/pad_menu_test.gd.uid new file mode 100644 index 0000000..96be7ca --- /dev/null +++ b/tools/test/pad_menu_test.gd.uid @@ -0,0 +1 @@ +uid://cgonoxtgka01j diff --git a/tools/test/pad_menu_test.tscn b/tools/test/pad_menu_test.tscn new file mode 100644 index 0000000..5dcfbf8 --- /dev/null +++ b/tools/test/pad_menu_test.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/test/pad_menu_test.gd" id="1"] + +[node name="T" type="Node"] +script = ExtResource("1") diff --git a/tools/test/parkour_check.txt b/tools/test/parkour_check.txt index d4afcf6..52d424d 100644 --- a/tools/test/parkour_check.txt +++ b/tools/test/parkour_check.txt @@ -1,6 +1,6 @@ # Проверка карты Parkour «по существу» после прохода через портал с настоящими пакетами. # Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...") -# и запусти: godot --headless --main-pack /index.pck -- --client --name=T --pack-url=http://127.0.0.1:<порт>/ --tp=2:4.5,1,-8 +# и запусти: godot --headless --main-pack /index.pck -- --client --name=T --pack-url=http://127.0.0.1:<порт>/ --tp=2:0,1,-15 # Ожидание (после рейтинга: таймер=true итоги=true рейтинг_класс=true): скрипт=true, шейдерных_мешей>=1, небо_панорама=true, музыка_играет=true, лазеров_и_ворот=10. # Если в пакете не хватает файла, вместо этого будут false/0 (а имя сцены всё равно «Parkour»). diff --git a/tools/test/pause_test.gd b/tools/test/pause_test.gd new file mode 100644 index 0000000..e9ed833 --- /dev/null +++ b/tools/test/pause_test.gd @@ -0,0 +1,138 @@ +extends Node +# Проверка меню паузы, настроек и геймпада без окна: +# godot --headless --path . res://tools/test/pause_test.tscn -- --client --server-url=ws://127.0.0.1:1 +# Печатает строки OK/FAIL и «ИТОГО». Хаб загружается как сцена-потомок; последний шаг проверяет «Выйти в хаб». +var level +var p +var fails := 0 + +func check(name: String, cond: bool) -> void: + print(("OK " if cond else "FAIL ") + name) + if not cond: + fails += 1 + +func act(action: String, pressed := true) -> void: + var e := InputEventAction.new() + e.action = action + e.pressed = pressed + Input.parse_input_event(e) + +func frames(n: int) -> void: + for i in n: + await get_tree().process_frame + +func _ready() -> void: + Net.nick_asked = true + level = (load("res://maps/hub/hub.tscn") as PackedScene).instantiate() + add_child(level) + p = level.get_node("Player") + await frames(5) + await _run() + print("ИТОГО: %s" % ("всё хорошо" if fails == 0 else "провалов %d" % fails)) + get_tree().quit(fails) + +func _run() -> void: + # --- раскладка геймпада в InputMap --- + var want := {"jump": 2, "sprint": 2, "crouch": 1, "dash": 2, "zoom": 2, "ready": 1, "leaderboard": 1, "giveup": 1, "pause": 2, + "move_forward": 2, "look_left": 1, "look_down": 1} + for a in want: + var pads := 0 + for e in InputMap.action_get_events(a): + if e is InputEventJoypadButton or e is InputEventJoypadMotion: + pads += 1 + check("геймпад: %s привязан (%d)" % [a, pads], pads >= 1 and (a != "jump" and a != "dash" or pads == 2)) + + # --- пауза --- + check("меню закрыто в начале", not PauseMenu.open) + act("pause") + await frames(3) + act("pause", false) + check("Esc/Start открывает меню", PauseMenu.open) + check("игрок заблокирован в меню", Net.input_blocked) + check("в хабе кнопки «Выйти в хаб» нет", not PauseMenu._hub_btn.visible) + act("pause") + await frames(3) + act("pause", false) + check("повторный Esc закрывает меню", not PauseMenu.open) + check("после закрытия игрок свободен", not Net.input_blocked) + + # --- настройки: мышь, инверсия, FOV --- + Settings.mouse_sens = 1.0 + Settings.invert_y = false + p.rotation.y = 0.0 + p.head.rotation.x = 0.0 + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + var m := Vector2(100, 50) # мышь в headless не «захвачена», поэтому зовём расчёт обзора напрямую + p._mouse_look(m) + var yaw1: float = absf(p.rotation.y) + var pitch1: float = p.head.rotation.x + Settings.mouse_sens = 2.0 + p.rotation.y = 0.0 + p.head.rotation.x = 0.0 + p._mouse_look(m) + check("чувствительность ×2 удваивает поворот (%.3f -> %.3f)" % [yaw1, absf(p.rotation.y)], absf(absf(p.rotation.y) / yaw1 - 2.0) < 0.05) + Settings.invert_y = true + p.head.rotation.x = 0.0 + p._mouse_look(m) + check("инверсия меняет знак наклона (%.3f и %.3f)" % [pitch1, p.head.rotation.x], pitch1 != 0.0 and signf(p.head.rotation.x) == -signf(pitch1)) + Settings.invert_y = false + Settings.mouse_sens = 1.0 + Settings.fov = 90.0 + await frames(3) + check("FOV из настроек применяется (%.0f)" % p.base_fov, is_equal_approx(p.base_fov, 90.0)) + Settings.fov = Settings.DEFAULT_FOV + + # --- обзор правым стиком --- + p.rotation.y = 0.0 + Input.action_press("look_right", 1.0) + await frames(10) + Input.action_release("look_right") + check("правый стик поворачивает обзор (%.2f рад)" % p.rotation.y, p.rotation.y < -0.1) + var before: float = p.rotation.y + Net.input_blocked = true + Input.action_press("look_right", 1.0) + await frames(5) + Input.action_release("look_right") + Net.input_blocked = false + check("в меню стик обзор не крутит", is_equal_approx(p.rotation.y, before)) + + # --- кнопки геймпада доходят до действий --- + var b := InputEventJoypadButton.new() + b.button_index = JOY_BUTTON_A + b.pressed = true + Input.parse_input_event(b) + await frames(2) + check("кнопка A = прыжок", Input.is_action_pressed("jump")) + b.pressed = false + Input.parse_input_event(b) + var t := InputEventJoypadMotion.new() + t.axis = JOY_AXIS_TRIGGER_RIGHT + t.axis_value = 1.0 + Input.parse_input_event(t) + await frames(2) + check("правый курок = спринт", Input.is_action_pressed("sprint")) + t.axis_value = 0.0 + Input.parse_input_event(t) + + # --- сохранение настроек --- + Settings.mouse_sens = 1.7 + Settings.save() + Settings.mouse_sens = 1.0 + Settings.load_settings() + check("настройки читаются из файла", is_equal_approx(Settings.mouse_sens, 1.7)) + Settings.reset_defaults() + Settings.save() + + # --- выход в хаб с другой карты --- + level.reparent(get_tree().root) # хаб — текущая сцена, чтобы смена сцены не убила сам тест + get_tree().current_scene = level + Net.current_map = "race" + act("pause") + await frames(3) + act("pause", false) + check("не в хабе кнопка «Выйти в хаб» видна", PauseMenu._hub_btn.visible) + PauseMenu._go_hub() + await frames(10) + check("«Выйти в хаб» закрыло меню", not PauseMenu.open and not Net.input_blocked) + check("«Выйти в хаб» открыло хаб (%s)" % get_tree().current_scene, get_tree().current_scene != null and get_tree().current_scene.name == "Hub") + check("карта сменилась на hub", Net.current_map == "hub") diff --git a/tools/test/pause_test.gd.uid b/tools/test/pause_test.gd.uid new file mode 100644 index 0000000..8f0540a --- /dev/null +++ b/tools/test/pause_test.gd.uid @@ -0,0 +1 @@ +uid://ck5rnfgqt7ndb diff --git a/tools/test/pause_test.tscn b/tools/test/pause_test.tscn new file mode 100644 index 0000000..5e2c710 --- /dev/null +++ b/tools/test/pause_test.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/test/pause_test.gd" id="1"] + +[node name="T" type="Node"] +script = ExtResource("1") diff --git a/tools/test/race2_check.txt b/tools/test/race2_check.txt index 2b4af63..5c84917 100644 --- a/tools/test/race2_check.txt +++ b/tools/test/race2_check.txt @@ -1,7 +1,7 @@ # Проверка карты Race 2 «по существу» после прохода через портал RACE 2 с настоящими пакетами (по образцу parkour_check.txt). # Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...") # Пакеты: tools/export_all.sh /tmp/site (НЕ в grid-world), затем (cd /tmp/site && python3 -m http.server 8777 --bind 127.0.0.1 &) и -# godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:9.0,1,-7.5 +# godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:12,1,-15 # (портал RACE 2 пятый в ряду: x = (4 - 2) * 4.5 = 9.0). Сервер в этом тесте не нужен. # Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=13, лазеров=10, вентиляторов=6, полос=15 (см. race2_data.gd), музыка_в_файле=true, сдача=true. diff --git a/tools/test/race_check.txt b/tools/test/race_check.txt index 6c70754..c3edb5f 100644 --- a/tools/test/race_check.txt +++ b/tools/test/race_check.txt @@ -1,7 +1,7 @@ # Проверка карты Race «по существу» после прохода через портал RACE с настоящими пакетами (по образцу parkour_check.txt). # Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...") # Пакеты: tools/export_all.sh /tmp/site (НЕ в grid-world), затем (cd /tmp/site && python3 -m http.server 8777 --bind 127.0.0.1 &) и -# godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:6.75,1,-7.5 +# godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:6,1,-15 # (портал RACE четвёртый в ряду: x = (3 - 1.5) * 4.5 = 6.75). Сервер в этом тесте не нужен. # Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true, сдача=true.