Геймпад Xbox, меню паузы с настройками, новый хаб с музыкой

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:36:50 +05:00
co-authored by Claude Sonnet 5.5
parent 0ffba7311f
commit 2d347d132b
27 changed files with 1220 additions and 41 deletions
+1 -1
View File
@@ -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=<секунда>:<x>,<y>,<z> — телепорт игрока на этой секунде (например, прямо в портал).
# Остальные аргументы пользователя (--server-url=..., --pack-url=...) читают Net и MapLoader.
+34
View File
@@ -0,0 +1,34 @@
extends Node
# Облёт хаба для кадров: телепорт игрока по точкам, кадры пишет Godot (--write-movie).
# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/h.png --quit-after 45 res://tools/test/hub_tour.tscn -- --client --server-url=ws://127.0.0.1:1
# Меню паузы: добавьте `--menu=main|settings|controls` (тогда в кадре меню на хабе, остальной облёт отключён).
# Кадры: h00000005.png и т. д. (каждые 5 кадров — следующая точка из spots).
var level
var p
var frame := 0
# позиция центра капсулы, рыскание (0 = на север, к порталам), наклон головы (градусы)
var spots := [
[Vector3(0, 1.25, 12), 0, 4], [Vector3(-10, 1.25, -6), 90, 8], [Vector3(10, 1.25, 8), -90, 6],
[Vector3(0, 1.25, -9), 0, 12], [Vector3(0, 1.25, -5), 180, 10], [Vector3(14, 1.25, -9), 0, 8],
[Vector3(-20, 1.25, 2), -90, 0], [Vector3(0, 6.0, 13), 0, -18],
]
func _ready() -> void:
Net.nick_asked = true # без панели «Введите ник»
level = (load("res://maps/hub/hub.tscn") as PackedScene).instantiate()
add_child(level)
p = level.get_node("Player")
p.set_physics_process(false)
func _process(_d: float) -> void:
frame += 1
for a in OS.get_cmdline_user_args():
if a.begins_with("--menu="):
if frame == 3:
PauseMenu.open_menu()
PauseMenu._show_page(a.substr(7))
return
var i := int(frame / 5)
if i < spots.size() and frame % 5 == 1:
var s = spots[i]
p.global_position = s[0]
p.rotation.y = deg_to_rad(s[1])
p.head.rotation.x = deg_to_rad(s[2])
+1
View File
@@ -0,0 +1 @@
uid://dndxmq38i6wyg
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/test/hub_tour.gd" id="1"]
[node name="T" type="Node"]
script = ExtResource("1")
+33
View File
@@ -0,0 +1,33 @@
extends Node
# Геймпад в меню паузы: фокус, A выбирает, крестовина двигает.
func frames(n: int) -> void:
for i in n:
await get_tree().process_frame
func btn(b: JoyButton, pressed: bool) -> void:
var e := InputEventJoypadButton.new()
e.button_index = b
e.pressed = pressed
Input.parse_input_event(e)
func _ready() -> void:
Net.nick_asked = true
add_child((load("res://maps/hub/hub.tscn") as PackedScene).instantiate())
await frames(5)
PauseMenu.open_menu()
await frames(5)
var f := get_viewport().gui_get_focus_owner()
print("фокус: ", f, " текст: ", f.text if f else "—")
btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2)
f = get_viewport().gui_get_focus_owner()
print("после вниз: ", f.text if f else "—")
btn(JOY_BUTTON_DPAD_UP, true); await frames(2); btn(JOY_BUTTON_DPAD_UP, false); await frames(2)
btn(JOY_BUTTON_A, true); await frames(2); btn(JOY_BUTTON_A, false); await frames(3)
print("после A на «Продолжить»: меню открыто = ", PauseMenu.open)
PauseMenu.open_menu(); await frames(5)
btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2)
btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2)
btn(JOY_BUTTON_DPAD_DOWN, true); await frames(2); btn(JOY_BUTTON_DPAD_DOWN, false); await frames(2)
f = get_viewport().gui_get_focus_owner()
print("фокус перед A: ", f.text if f else "—")
btn(JOY_BUTTON_A, true); await frames(2); btn(JOY_BUTTON_A, false); await frames(3)
print("страница после A: ", PauseMenu._page)
get_tree().quit()
+1
View File
@@ -0,0 +1 @@
uid://cgonoxtgka01j
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/test/pad_menu_test.gd" id="1"]
[node name="T" type="Node"]
script = ExtResource("1")
+1 -1
View File
@@ -1,6 +1,6 @@
# Проверка карты Parkour «по существу» после прохода через портал с настоящими пакетами.
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...")
# и запусти: godot --headless --main-pack <site>/index.pck -- --client --name=T --pack-url=http://127.0.0.1:<порт>/ --tp=2:4.5,1,-8
# и запусти: godot --headless --main-pack <site>/index.pck -- --client --name=T --pack-url=http://127.0.0.1:<порт>/ --tp=2:0,1,-15
# Ожидание (после рейтинга: таймер=true итоги=true рейтинг_класс=true): скрипт=true, шейдерных_мешей>=1, небо_панорама=true, музыка_играет=true, лазеров_и_ворот=10.
# Если в пакете не хватает файла, вместо этого будут false/0 (а имя сцены всё равно «Parkour»).
+138
View File
@@ -0,0 +1,138 @@
extends Node
# Проверка меню паузы, настроек и геймпада без окна:
# godot --headless --path . res://tools/test/pause_test.tscn -- --client --server-url=ws://127.0.0.1:1
# Печатает строки OK/FAIL и «ИТОГО». Хаб загружается как сцена-потомок; последний шаг проверяет «Выйти в хаб».
var level
var p
var fails := 0
func check(name: String, cond: bool) -> void:
print(("OK " if cond else "FAIL ") + name)
if not cond:
fails += 1
func act(action: String, pressed := true) -> void:
var e := InputEventAction.new()
e.action = action
e.pressed = pressed
Input.parse_input_event(e)
func frames(n: int) -> void:
for i in n:
await get_tree().process_frame
func _ready() -> void:
Net.nick_asked = true
level = (load("res://maps/hub/hub.tscn") as PackedScene).instantiate()
add_child(level)
p = level.get_node("Player")
await frames(5)
await _run()
print("ИТОГО: %s" % ("всё хорошо" if fails == 0 else "провалов %d" % fails))
get_tree().quit(fails)
func _run() -> void:
# --- раскладка геймпада в InputMap ---
var want := {"jump": 2, "sprint": 2, "crouch": 1, "dash": 2, "zoom": 2, "ready": 1, "leaderboard": 1, "giveup": 1, "pause": 2,
"move_forward": 2, "look_left": 1, "look_down": 1}
for a in want:
var pads := 0
for e in InputMap.action_get_events(a):
if e is InputEventJoypadButton or e is InputEventJoypadMotion:
pads += 1
check("геймпад: %s привязан (%d)" % [a, pads], pads >= 1 and (a != "jump" and a != "dash" or pads == 2))
# --- пауза ---
check("меню закрыто в начале", not PauseMenu.open)
act("pause")
await frames(3)
act("pause", false)
check("Esc/Start открывает меню", PauseMenu.open)
check("игрок заблокирован в меню", Net.input_blocked)
check("в хабе кнопки «Выйти в хаб» нет", not PauseMenu._hub_btn.visible)
act("pause")
await frames(3)
act("pause", false)
check("повторный Esc закрывает меню", not PauseMenu.open)
check("после закрытия игрок свободен", not Net.input_blocked)
# --- настройки: мышь, инверсия, FOV ---
Settings.mouse_sens = 1.0
Settings.invert_y = false
p.rotation.y = 0.0
p.head.rotation.x = 0.0
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
var m := Vector2(100, 50) # мышь в headless не «захвачена», поэтому зовём расчёт обзора напрямую
p._mouse_look(m)
var yaw1: float = absf(p.rotation.y)
var pitch1: float = p.head.rotation.x
Settings.mouse_sens = 2.0
p.rotation.y = 0.0
p.head.rotation.x = 0.0
p._mouse_look(m)
check("чувствительность ×2 удваивает поворот (%.3f -> %.3f)" % [yaw1, absf(p.rotation.y)], absf(absf(p.rotation.y) / yaw1 - 2.0) < 0.05)
Settings.invert_y = true
p.head.rotation.x = 0.0
p._mouse_look(m)
check("инверсия меняет знак наклона (%.3f и %.3f)" % [pitch1, p.head.rotation.x], pitch1 != 0.0 and signf(p.head.rotation.x) == -signf(pitch1))
Settings.invert_y = false
Settings.mouse_sens = 1.0
Settings.fov = 90.0
await frames(3)
check("FOV из настроек применяется (%.0f)" % p.base_fov, is_equal_approx(p.base_fov, 90.0))
Settings.fov = Settings.DEFAULT_FOV
# --- обзор правым стиком ---
p.rotation.y = 0.0
Input.action_press("look_right", 1.0)
await frames(10)
Input.action_release("look_right")
check("правый стик поворачивает обзор (%.2f рад)" % p.rotation.y, p.rotation.y < -0.1)
var before: float = p.rotation.y
Net.input_blocked = true
Input.action_press("look_right", 1.0)
await frames(5)
Input.action_release("look_right")
Net.input_blocked = false
check("в меню стик обзор не крутит", is_equal_approx(p.rotation.y, before))
# --- кнопки геймпада доходят до действий ---
var b := InputEventJoypadButton.new()
b.button_index = JOY_BUTTON_A
b.pressed = true
Input.parse_input_event(b)
await frames(2)
check("кнопка A = прыжок", Input.is_action_pressed("jump"))
b.pressed = false
Input.parse_input_event(b)
var t := InputEventJoypadMotion.new()
t.axis = JOY_AXIS_TRIGGER_RIGHT
t.axis_value = 1.0
Input.parse_input_event(t)
await frames(2)
check("правый курок = спринт", Input.is_action_pressed("sprint"))
t.axis_value = 0.0
Input.parse_input_event(t)
# --- сохранение настроек ---
Settings.mouse_sens = 1.7
Settings.save()
Settings.mouse_sens = 1.0
Settings.load_settings()
check("настройки читаются из файла", is_equal_approx(Settings.mouse_sens, 1.7))
Settings.reset_defaults()
Settings.save()
# --- выход в хаб с другой карты ---
level.reparent(get_tree().root) # хаб — текущая сцена, чтобы смена сцены не убила сам тест
get_tree().current_scene = level
Net.current_map = "race"
act("pause")
await frames(3)
act("pause", false)
check("не в хабе кнопка «Выйти в хаб» видна", PauseMenu._hub_btn.visible)
PauseMenu._go_hub()
await frames(10)
check("«Выйти в хаб» закрыло меню", not PauseMenu.open and not Net.input_blocked)
check("«Выйти в хаб» открыло хаб (%s)" % get_tree().current_scene, get_tree().current_scene != null and get_tree().current_scene.name == "Hub")
check("карта сменилась на hub", Net.current_map == "hub")
+1
View File
@@ -0,0 +1 @@
uid://ck5rnfgqt7ndb
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/test/pause_test.gd" id="1"]
[node name="T" type="Node"]
script = ExtResource("1")
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.