Files
my-world/core/pause_menu.gd
T
aibrashandClaude Sonnet 5.5 2d347d132b Геймпад 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>
2026-10-31 18:36:50 +05:00

332 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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