Геймпад 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
+331
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cyvtp5hwd4h5o
+147
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bkyvk0c3jox51