first commit
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
class_name GameSettingsService
|
||||
extends Node
|
||||
## Глобальные настройки игры: хранятся в user://settings.cfg и применяются к движку.
|
||||
##
|
||||
## Зарегистрирован автозагрузкой, поэтому доступен отовсюду как GameSettings.
|
||||
## UI (scenes/ui/settings_menu.tscn) только читает и вызывает сеттеры —
|
||||
## вся работа с DisplayServer, AudioServer и InputMap живёт здесь.
|
||||
|
||||
const CONFIG_PATH := "user://settings.cfg"
|
||||
|
||||
enum WindowMode { WINDOWED, BORDERLESS, FULLSCREEN }
|
||||
|
||||
## Режимы окна в порядке, в котором они показываются в выпадающем списке.
|
||||
const WINDOW_MODE_LABELS: Array[String] = ["Оконный", "Без рамки", "Полноэкранный"]
|
||||
|
||||
## Действия, доступные для переназначения, и их подписи в UI.
|
||||
const ACTION_LABELS := {
|
||||
"move_forward": "Вперёд",
|
||||
"move_back": "Назад",
|
||||
"move_left": "Влево",
|
||||
"move_right": "Вправо",
|
||||
"jump": "Прыжок",
|
||||
"sprint": "Бег",
|
||||
"interact": "Взаимодействие",
|
||||
"pause": "Пауза",
|
||||
}
|
||||
|
||||
## Шины микшера, громкость которых регулируется в настройках.
|
||||
const AUDIO_BUSES := {
|
||||
"Master": "Общая",
|
||||
"Music": "Музыка",
|
||||
"SFX": "Эффекты",
|
||||
}
|
||||
|
||||
const RESOLUTIONS: Array[Vector2i] = [
|
||||
Vector2i(1280, 720),
|
||||
Vector2i(1600, 900),
|
||||
Vector2i(1920, 1080),
|
||||
Vector2i(2560, 1440),
|
||||
Vector2i(3840, 2160),
|
||||
]
|
||||
|
||||
## 0 — без ограничения.
|
||||
const FPS_LIMITS: Array[int] = [0, 30, 60, 90, 120, 144, 240]
|
||||
|
||||
## Любое изменение настроек — чтобы открытые экраны могли обновить себя.
|
||||
signal changed
|
||||
|
||||
var window_mode: int = WindowMode.WINDOWED
|
||||
var resolution := Vector2i(1920, 1080)
|
||||
var vsync := true
|
||||
var fps_limit := 0
|
||||
var volumes := {"Master": 1.0, "Music": 0.8, "SFX": 0.8}
|
||||
## action: String -> keycode: int
|
||||
var bindings := {}
|
||||
|
||||
var _default_bindings := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_capture_default_bindings()
|
||||
_read_current_state()
|
||||
if load_settings():
|
||||
apply_all()
|
||||
else:
|
||||
# Первый запуск: ничего не навязываем окну, просто фиксируем как есть.
|
||||
save_settings()
|
||||
|
||||
|
||||
# --- Чтение / запись --------------------------------------------------------
|
||||
|
||||
func load_settings() -> bool:
|
||||
var config := ConfigFile.new()
|
||||
if config.load(CONFIG_PATH) != OK:
|
||||
return false
|
||||
|
||||
window_mode = config.get_value("video", "window_mode", window_mode)
|
||||
resolution = config.get_value("video", "resolution", resolution)
|
||||
vsync = config.get_value("video", "vsync", vsync)
|
||||
fps_limit = config.get_value("video", "fps_limit", fps_limit)
|
||||
|
||||
for bus_name: String in AUDIO_BUSES:
|
||||
var value: float = config.get_value("audio", bus_name, volumes[bus_name])
|
||||
volumes[bus_name] = clampf(value, 0.0, 1.0)
|
||||
|
||||
for action: String in ACTION_LABELS:
|
||||
bindings[action] = config.get_value("input", action, _default_bindings.get(action, KEY_NONE))
|
||||
|
||||
return true
|
||||
|
||||
|
||||
func save_settings() -> void:
|
||||
var config := ConfigFile.new()
|
||||
config.set_value("video", "window_mode", window_mode)
|
||||
config.set_value("video", "resolution", resolution)
|
||||
config.set_value("video", "vsync", vsync)
|
||||
config.set_value("video", "fps_limit", fps_limit)
|
||||
for bus_name: String in AUDIO_BUSES:
|
||||
config.set_value("audio", bus_name, volumes[bus_name])
|
||||
for action: String in bindings:
|
||||
config.set_value("input", action, bindings[action])
|
||||
config.save(CONFIG_PATH)
|
||||
|
||||
|
||||
func reset_to_defaults() -> void:
|
||||
window_mode = WindowMode.WINDOWED
|
||||
resolution = Vector2i(
|
||||
ProjectSettings.get_setting("display/window/size/viewport_width", 1920),
|
||||
ProjectSettings.get_setting("display/window/size/viewport_height", 1080),
|
||||
)
|
||||
vsync = true
|
||||
fps_limit = 0
|
||||
volumes = {"Master": 1.0, "Music": 0.8, "SFX": 0.8}
|
||||
bindings = _default_bindings.duplicate()
|
||||
apply_all()
|
||||
save_settings()
|
||||
changed.emit()
|
||||
|
||||
|
||||
# --- Применение к движку ----------------------------------------------------
|
||||
|
||||
func apply_all() -> void:
|
||||
apply_video()
|
||||
apply_audio()
|
||||
apply_input()
|
||||
|
||||
|
||||
func apply_video() -> void:
|
||||
match window_mode:
|
||||
WindowMode.WINDOWED:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, false)
|
||||
DisplayServer.window_set_size(resolution)
|
||||
_center_window()
|
||||
WindowMode.BORDERLESS:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
||||
var screen := DisplayServer.window_get_current_screen()
|
||||
DisplayServer.window_set_size(DisplayServer.screen_get_size(screen))
|
||||
DisplayServer.window_set_position(DisplayServer.screen_get_position(screen))
|
||||
WindowMode.FULLSCREEN:
|
||||
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, false)
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
||||
|
||||
DisplayServer.window_set_vsync_mode(
|
||||
DisplayServer.VSYNC_ENABLED if vsync else DisplayServer.VSYNC_DISABLED
|
||||
)
|
||||
Engine.max_fps = fps_limit
|
||||
|
||||
|
||||
func apply_audio() -> void:
|
||||
for bus_name: String in volumes:
|
||||
var index := AudioServer.get_bus_index(bus_name)
|
||||
if index < 0:
|
||||
continue
|
||||
var value: float = volumes[bus_name]
|
||||
AudioServer.set_bus_mute(index, is_zero_approx(value))
|
||||
AudioServer.set_bus_volume_db(index, linear_to_db(maxf(value, 0.0001)))
|
||||
|
||||
|
||||
func apply_input() -> void:
|
||||
for action: String in bindings:
|
||||
_apply_action(action)
|
||||
|
||||
|
||||
# --- Сеттеры (UI дёргает только их) -----------------------------------------
|
||||
|
||||
func set_window_mode(mode: int) -> void:
|
||||
window_mode = mode
|
||||
apply_video()
|
||||
_commit()
|
||||
|
||||
|
||||
func set_resolution(value: Vector2i) -> void:
|
||||
resolution = value
|
||||
apply_video()
|
||||
_commit()
|
||||
|
||||
|
||||
func set_vsync(enabled: bool) -> void:
|
||||
vsync = enabled
|
||||
DisplayServer.window_set_vsync_mode(
|
||||
DisplayServer.VSYNC_ENABLED if enabled else DisplayServer.VSYNC_DISABLED
|
||||
)
|
||||
_commit()
|
||||
|
||||
|
||||
func set_fps_limit(value: int) -> void:
|
||||
fps_limit = value
|
||||
Engine.max_fps = value
|
||||
_commit()
|
||||
|
||||
|
||||
func set_volume(bus_name: String, value: float) -> void:
|
||||
volumes[bus_name] = clampf(value, 0.0, 1.0)
|
||||
apply_audio()
|
||||
_commit()
|
||||
|
||||
|
||||
## Вешает клавишу на действие. Если клавиша уже занята, снимает её с прежнего
|
||||
## действия и возвращает его имя — вызывающий может показать предупреждение.
|
||||
func set_binding(action: String, keycode: int) -> String:
|
||||
var conflict := ""
|
||||
for other: String in bindings:
|
||||
if other != action and int(bindings[other]) == keycode:
|
||||
conflict = other
|
||||
bindings[other] = KEY_NONE
|
||||
_apply_action(other)
|
||||
bindings[action] = keycode
|
||||
_apply_action(action)
|
||||
_commit()
|
||||
return conflict
|
||||
|
||||
|
||||
## Подпись клавиши для UI.
|
||||
static func key_name(keycode: int) -> String:
|
||||
if keycode == KEY_NONE:
|
||||
return "—"
|
||||
return OS.get_keycode_string(keycode)
|
||||
|
||||
|
||||
# --- Внутреннее -------------------------------------------------------------
|
||||
|
||||
func _commit() -> void:
|
||||
save_settings()
|
||||
changed.emit()
|
||||
|
||||
|
||||
func _apply_action(action: String) -> void:
|
||||
if not InputMap.has_action(action):
|
||||
return
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
if event is InputEventKey:
|
||||
InputMap.action_erase_event(action, event)
|
||||
var keycode := int(bindings.get(action, KEY_NONE))
|
||||
if keycode == KEY_NONE:
|
||||
return
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = keycode as Key
|
||||
InputMap.action_add_event(action, event)
|
||||
|
||||
|
||||
func _capture_default_bindings() -> void:
|
||||
for action: String in ACTION_LABELS:
|
||||
var keycode := KEY_NONE
|
||||
if InputMap.has_action(action):
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
if event is InputEventKey:
|
||||
var key_event := event as InputEventKey
|
||||
keycode = key_event.keycode if key_event.keycode != KEY_NONE else key_event.physical_keycode
|
||||
break
|
||||
_default_bindings[action] = keycode
|
||||
bindings[action] = keycode
|
||||
|
||||
|
||||
func _read_current_state() -> void:
|
||||
resolution = DisplayServer.window_get_size()
|
||||
vsync = DisplayServer.window_get_vsync_mode() != DisplayServer.VSYNC_DISABLED
|
||||
fps_limit = Engine.max_fps
|
||||
|
||||
# Безрамочность намеренно не считываем: запуск из редактора отдаёт окно без
|
||||
# рамки (встроенное окно игры), и это попало бы в конфиг как выбор игрока.
|
||||
if DisplayServer.window_get_mode() in [
|
||||
DisplayServer.WINDOW_MODE_FULLSCREEN,
|
||||
DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN,
|
||||
]:
|
||||
window_mode = WindowMode.FULLSCREEN
|
||||
else:
|
||||
window_mode = WindowMode.WINDOWED
|
||||
|
||||
for bus_name: String in AUDIO_BUSES:
|
||||
var index := AudioServer.get_bus_index(bus_name)
|
||||
if index >= 0:
|
||||
volumes[bus_name] = db_to_linear(AudioServer.get_bus_volume_db(index))
|
||||
|
||||
|
||||
func _center_window() -> void:
|
||||
var screen := DisplayServer.window_get_current_screen()
|
||||
var screen_rect := Rect2i(
|
||||
DisplayServer.screen_get_position(screen), DisplayServer.screen_get_size(screen)
|
||||
)
|
||||
var window_size := DisplayServer.window_get_size()
|
||||
@warning_ignore("integer_division")
|
||||
var offset := (screen_rect.size - window_size) / 2
|
||||
DisplayServer.window_set_position(screen_rect.position + offset)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dt0wp3ov76ad8
|
||||
@@ -0,0 +1,243 @@
|
||||
class_name SaveManagerService
|
||||
extends Node
|
||||
## Слоты сохранений: запись, чтение, удаление и метаданные для UI.
|
||||
##
|
||||
## Зарегистрирован автозагрузкой, доступен как SaveManager.
|
||||
##
|
||||
## Контракт для игровых сцен:
|
||||
## 1. в _ready() вызвать begin_session(scene_file_path);
|
||||
## 2. узлы, которые нужно сохранять, положить в группу "persistent"
|
||||
## и дать им методы save_data() -> Dictionary и load_data(Dictionary).
|
||||
|
||||
const SAVE_DIR := "user://saves"
|
||||
const SLOT_COUNT := 6
|
||||
const PERSIST_GROUP := "persistent"
|
||||
const FORMAT_VERSION := 1
|
||||
|
||||
## Слот записан или удалён — экранам пора обновить список.
|
||||
signal slots_changed
|
||||
|
||||
## Наигранное время текущей сессии, секунды.
|
||||
var playtime := 0.0
|
||||
## Считать ли время: включается игровой сценой, в меню стоит.
|
||||
var tracking := false
|
||||
## Сцена, которая будет записана в слот и открыта при загрузке.
|
||||
var current_scene_path := ""
|
||||
|
||||
## Кадр игры, снятый до открытия меню паузы, — миниатюра для слота.
|
||||
var _thumbnail: Image = null
|
||||
var _pending_payload := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if tracking:
|
||||
playtime += delta
|
||||
|
||||
|
||||
## Вызывается игровой сценой при старте (и новой игры, и загрузки).
|
||||
func begin_session(scene_path: String) -> void:
|
||||
current_scene_path = scene_path
|
||||
tracking = true
|
||||
|
||||
|
||||
func end_session() -> void:
|
||||
tracking = false
|
||||
|
||||
|
||||
# --- Слоты ------------------------------------------------------------------
|
||||
|
||||
## Метаданные всех слотов по порядку — ровно то, что рисует UI.
|
||||
func list_slots() -> Array[Dictionary]:
|
||||
var slots: Array[Dictionary] = []
|
||||
for index in SLOT_COUNT:
|
||||
slots.append(slot_info(index))
|
||||
return slots
|
||||
|
||||
|
||||
func slot_info(index: int) -> Dictionary:
|
||||
var info := {
|
||||
"index": index,
|
||||
"exists": false,
|
||||
"saved_at": 0,
|
||||
"playtime": 0.0,
|
||||
"scene_path": "",
|
||||
"thumbnail": null,
|
||||
}
|
||||
|
||||
var data := _read_slot(index)
|
||||
if data.is_empty():
|
||||
return info
|
||||
|
||||
info["exists"] = true
|
||||
info["saved_at"] = int(data.get("saved_at", 0))
|
||||
info["playtime"] = float(data.get("playtime", 0.0))
|
||||
info["scene_path"] = String(data.get("scene_path", ""))
|
||||
info["thumbnail"] = _load_thumbnail(index)
|
||||
return info
|
||||
|
||||
|
||||
func has_any_save() -> bool:
|
||||
for index in SLOT_COUNT:
|
||||
if FileAccess.file_exists(_slot_path(index)):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Индекс самого свежего слота или -1, если сохранений нет.
|
||||
func latest_slot() -> int:
|
||||
var best := -1
|
||||
var best_time := -1
|
||||
for index in SLOT_COUNT:
|
||||
var data := _read_slot(index)
|
||||
if data.is_empty():
|
||||
continue
|
||||
var saved_at := int(data.get("saved_at", 0))
|
||||
if saved_at > best_time:
|
||||
best_time = saved_at
|
||||
best = index
|
||||
return best
|
||||
|
||||
|
||||
func save_to_slot(index: int) -> bool:
|
||||
if current_scene_path.is_empty():
|
||||
push_warning("SaveManager: нечего сохранять — сессия не начата.")
|
||||
return false
|
||||
|
||||
var nodes := {}
|
||||
for node: Node in get_tree().get_nodes_in_group(PERSIST_GROUP):
|
||||
if node.has_method("save_data"):
|
||||
nodes[String(node.get_path())] = node.save_data()
|
||||
|
||||
var data := {
|
||||
"version": FORMAT_VERSION,
|
||||
"saved_at": int(Time.get_unix_time_from_system()),
|
||||
"playtime": playtime,
|
||||
"scene_path": current_scene_path,
|
||||
"nodes": nodes,
|
||||
}
|
||||
|
||||
var file := FileAccess.open(_slot_path(index), FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SaveManager: не удалось открыть слот %d на запись." % index)
|
||||
return false
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.close()
|
||||
|
||||
_write_thumbnail(index)
|
||||
slots_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func load_slot(index: int) -> bool:
|
||||
var data := _read_slot(index)
|
||||
if data.is_empty():
|
||||
return false
|
||||
|
||||
var scene_path := String(data.get("scene_path", ""))
|
||||
if not ResourceLoader.exists(scene_path):
|
||||
push_error("SaveManager: сцена из слота %d не найдена: %s" % [index, scene_path])
|
||||
return false
|
||||
|
||||
_pending_payload = data.get("nodes", {})
|
||||
playtime = float(data.get("playtime", 0.0))
|
||||
|
||||
get_tree().paused = false
|
||||
get_tree().change_scene_to_file(scene_path)
|
||||
|
||||
# Смена сцены отложенная: ждём, пока новое дерево встанет на место.
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
_apply_pending_payload()
|
||||
return true
|
||||
|
||||
|
||||
func delete_slot(index: int) -> void:
|
||||
for path: String in [_slot_path(index), _thumbnail_path(index)]:
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
slots_changed.emit()
|
||||
|
||||
|
||||
## Снимок последнего отрисованного кадра. Вызывать до показа меню, иначе
|
||||
## в миниатюру попадёт само меню.
|
||||
func capture_thumbnail() -> void:
|
||||
var texture := get_viewport().get_texture()
|
||||
if texture == null:
|
||||
return
|
||||
var image := texture.get_image()
|
||||
if image == null:
|
||||
return
|
||||
image.resize(384, 216, Image.INTERPOLATE_LANCZOS)
|
||||
_thumbnail = image
|
||||
|
||||
|
||||
# --- Форматирование для UI --------------------------------------------------
|
||||
|
||||
static func format_timestamp(unix_time: int) -> String:
|
||||
var zone := Time.get_time_zone_from_system()
|
||||
var local := unix_time + int(zone.get("bias", 0)) * 60
|
||||
var stamp := Time.get_datetime_dict_from_unix_time(local)
|
||||
return "%02d.%02d.%d %02d:%02d" % [
|
||||
stamp["day"], stamp["month"], stamp["year"], stamp["hour"], stamp["minute"],
|
||||
]
|
||||
|
||||
|
||||
static func format_playtime(seconds: float) -> String:
|
||||
var total := int(seconds)
|
||||
@warning_ignore("integer_division")
|
||||
var hours := total / 3600
|
||||
@warning_ignore("integer_division")
|
||||
var minutes := (total % 3600) / 60
|
||||
if hours > 0:
|
||||
return "%d ч %02d мин" % [hours, minutes]
|
||||
return "%d мин" % minutes
|
||||
|
||||
|
||||
# --- Внутреннее -------------------------------------------------------------
|
||||
|
||||
func _slot_path(index: int) -> String:
|
||||
return "%s/slot_%d.json" % [SAVE_DIR, index]
|
||||
|
||||
|
||||
func _thumbnail_path(index: int) -> String:
|
||||
return "%s/slot_%d.png" % [SAVE_DIR, index]
|
||||
|
||||
|
||||
func _read_slot(index: int) -> Dictionary:
|
||||
var path := _slot_path(index)
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if parsed is Dictionary:
|
||||
return parsed
|
||||
push_warning("SaveManager: слот %d повреждён." % index)
|
||||
return {}
|
||||
|
||||
|
||||
func _write_thumbnail(index: int) -> void:
|
||||
if _thumbnail == null:
|
||||
return
|
||||
_thumbnail.save_png(_thumbnail_path(index))
|
||||
|
||||
|
||||
func _load_thumbnail(index: int) -> Texture2D:
|
||||
var path := _thumbnail_path(index)
|
||||
if not FileAccess.file_exists(path):
|
||||
return null
|
||||
var image := Image.load_from_file(path)
|
||||
if image == null:
|
||||
return null
|
||||
return ImageTexture.create_from_image(image)
|
||||
|
||||
|
||||
func _apply_pending_payload() -> void:
|
||||
for node: Node in get_tree().get_nodes_in_group(PERSIST_GROUP):
|
||||
var key := String(node.get_path())
|
||||
if _pending_payload.has(key) and node.has_method("load_data"):
|
||||
node.load_data(_pending_payload[key])
|
||||
_pending_payload = {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c10fe0cdsjjc0
|
||||
@@ -0,0 +1,28 @@
|
||||
extends Node3D
|
||||
## Сцена-заглушка вместо настоящей игры.
|
||||
##
|
||||
## Нужна ровно для двух вещей: показать контракт с SaveManager и дать
|
||||
## меню паузы что останавливать. Куб крутится только когда игра не на паузе,
|
||||
## а его угол попадает в сохранение — по нему видно, что загрузка работает.
|
||||
|
||||
const SPIN_SPEED := 0.6
|
||||
|
||||
@onready var _saves: SaveManagerService = get_node("/root/SaveManager")
|
||||
@onready var _box: Node3D = $BoxA
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group(SaveManagerService.PERSIST_GROUP)
|
||||
_saves.begin_session(scene_file_path)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_box.rotate_y(delta * SPIN_SPEED)
|
||||
|
||||
|
||||
func save_data() -> Dictionary:
|
||||
return {"box_rotation": _box.rotation.y}
|
||||
|
||||
|
||||
func load_data(data: Dictionary) -> void:
|
||||
_box.rotation.y = float(data.get("box_rotation", 0.0))
|
||||
@@ -0,0 +1 @@
|
||||
uid://1viyqekbic0n
|
||||
@@ -0,0 +1,177 @@
|
||||
extends Control
|
||||
## Главное меню GWorld.
|
||||
##
|
||||
## Визуал собран из трёх частей: анимированный фон (shaders/menu_background.gdshader),
|
||||
## тема кнопок (themes/main_menu.theme.tres) и этот скрипт — появление, наведение
|
||||
## и вызов экранов настроек, сохранений и «Об игре».
|
||||
|
||||
## Сцена, в которую уходим по «Новая игра».
|
||||
@export var game_scene: PackedScene
|
||||
|
||||
## Игрок нажал «Продолжить» — до того, как начнётся загрузка слота.
|
||||
signal continue_requested
|
||||
## Игрок нажал «Новая игра».
|
||||
signal new_game_requested
|
||||
|
||||
const INTRO_TIME := 0.5
|
||||
const HOVER_SCALE := 1.035
|
||||
|
||||
@onready var _saves: SaveManagerService = get_node("/root/SaveManager")
|
||||
@onready var _header: Control = $Content/Column/Header
|
||||
@onready var _accent_bar: ColorRect = $Content/Column/Header/AccentBar
|
||||
@onready var _menu: VBoxContainer = $Content/Column/Menu
|
||||
@onready var _footer: Control = $Footer
|
||||
@onready var _continue_button: Button = $Content/Column/Menu/ContinueButton
|
||||
@onready var _load_button: Button = $Content/Column/Menu/LoadButton
|
||||
@onready var _settings_screen: Control = $SettingsScreen
|
||||
@onready var _save_screen: SaveMenuScreen = $SaveScreen
|
||||
@onready var _about_overlay: Control = $AboutOverlay
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# В меню время не идёт: сессия считается только внутри игровой сцены.
|
||||
_saves.end_session()
|
||||
|
||||
var has_saves := _saves.has_any_save()
|
||||
_continue_button.visible = has_saves
|
||||
_load_button.visible = has_saves
|
||||
|
||||
_wire_menu_buttons()
|
||||
|
||||
_settings_screen.closed.connect(_focus_first_button)
|
||||
_save_screen.closed.connect(_focus_first_button)
|
||||
var about_back: Button = $AboutOverlay/Center/Card/Body/Actions/AboutBackButton
|
||||
about_back.pressed.connect(_close_about)
|
||||
|
||||
_animate_accent_bar()
|
||||
await _play_intro()
|
||||
_focus_first_button()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _about_overlay.visible or not event.is_action_pressed("ui_cancel"):
|
||||
return
|
||||
_close_about()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
# --- Меню -------------------------------------------------------------------
|
||||
|
||||
func _wire_menu_buttons() -> void:
|
||||
for button: Button in _menu.get_children():
|
||||
# Мышь и клавиатура делят одну подсветку: наведение = фокус.
|
||||
button.mouse_entered.connect(button.grab_focus)
|
||||
button.focus_entered.connect(_on_button_focus.bind(button, true))
|
||||
button.focus_exited.connect(_on_button_focus.bind(button, false))
|
||||
button.pressed.connect(_on_menu_pressed.bind(button.name))
|
||||
|
||||
|
||||
func _on_button_focus(button: Button, focused: bool) -> void:
|
||||
button.pivot_offset = Vector2(0.0, button.size.y * 0.5)
|
||||
var target := Vector2.ONE * (HOVER_SCALE if focused else 1.0)
|
||||
var tween := create_tween().set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
|
||||
tween.tween_property(button, "scale", target, 0.18)
|
||||
|
||||
|
||||
func _on_menu_pressed(button_name: StringName) -> void:
|
||||
match String(button_name):
|
||||
"ContinueButton":
|
||||
continue_requested.emit()
|
||||
_continue_latest()
|
||||
"NewGameButton":
|
||||
new_game_requested.emit()
|
||||
_start_game()
|
||||
"LoadButton":
|
||||
_save_screen.open(SaveMenuScreen.Mode.LOAD)
|
||||
"SettingsButton":
|
||||
_settings_screen.open()
|
||||
"AboutButton":
|
||||
_open_about()
|
||||
"QuitButton":
|
||||
_quit()
|
||||
|
||||
|
||||
func _focus_first_button() -> void:
|
||||
for button: Button in _menu.get_children():
|
||||
if button.visible:
|
||||
button.grab_focus()
|
||||
return
|
||||
|
||||
|
||||
func _continue_latest() -> void:
|
||||
var slot := _saves.latest_slot()
|
||||
if slot < 0:
|
||||
return
|
||||
# Не ждём: смена сцены уничтожит это меню вместе со старым деревом.
|
||||
_saves.load_slot(slot)
|
||||
|
||||
|
||||
func _start_game() -> void:
|
||||
if game_scene == null:
|
||||
push_warning("MainMenu: game_scene не назначена — назначьте её в инспекторе.")
|
||||
return
|
||||
get_tree().change_scene_to_packed(game_scene)
|
||||
|
||||
|
||||
func _quit() -> void:
|
||||
var tween := create_tween()
|
||||
tween.tween_property(self, "modulate:a", 0.0, 0.25)
|
||||
await tween.finished
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
# --- «Об игре» --------------------------------------------------------------
|
||||
|
||||
func _open_about() -> void:
|
||||
_about_overlay.modulate.a = 0.0
|
||||
_about_overlay.visible = true
|
||||
# Контейнеры раскладываются только после того, как узел стал видимым.
|
||||
await get_tree().process_frame
|
||||
|
||||
var card: Control = _about_overlay.get_node("Center/Card")
|
||||
card.pivot_offset = card.size * 0.5
|
||||
card.scale = Vector2(0.96, 0.96)
|
||||
|
||||
var tween := create_tween().set_parallel(true).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
|
||||
tween.tween_property(_about_overlay, "modulate:a", 1.0, 0.18)
|
||||
tween.tween_property(card, "scale", Vector2.ONE, 0.22)
|
||||
|
||||
var back_button: Button = card.get_node("Body/Actions").get_child(0)
|
||||
back_button.grab_focus()
|
||||
|
||||
|
||||
func _close_about() -> void:
|
||||
var tween := create_tween().set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_CUBIC)
|
||||
tween.tween_property(_about_overlay, "modulate:a", 0.0, 0.15)
|
||||
await tween.finished
|
||||
_about_overlay.visible = false
|
||||
_focus_first_button()
|
||||
|
||||
|
||||
# --- Анимация ---------------------------------------------------------------
|
||||
|
||||
func _play_intro() -> void:
|
||||
var tween := create_tween().set_parallel(true).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
|
||||
|
||||
_header.modulate.a = 0.0
|
||||
tween.tween_property(_header, "modulate:a", 1.0, INTRO_TIME)
|
||||
|
||||
_footer.modulate.a = 0.0
|
||||
tween.tween_property(_footer, "modulate:a", 1.0, INTRO_TIME).set_delay(0.35)
|
||||
|
||||
var index := 0
|
||||
for button: Button in _menu.get_children():
|
||||
if not button.visible:
|
||||
continue
|
||||
button.modulate.a = 0.0
|
||||
tween.tween_property(button, "modulate:a", 1.0, 0.35).set_delay(0.2 + index * 0.07)
|
||||
index += 1
|
||||
|
||||
await tween.finished
|
||||
|
||||
|
||||
func _animate_accent_bar() -> void:
|
||||
# Медленное «дыхание» акцентной черты под заголовком.
|
||||
var tween := create_tween().set_loops().set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_SINE)
|
||||
tween.tween_property(_accent_bar, "custom_minimum_size:x", 230.0, 2.6)
|
||||
tween.tween_property(_accent_bar, "custom_minimum_size:x", 140.0, 2.6)
|
||||
@@ -0,0 +1 @@
|
||||
uid://3plo51qjjlpl
|
||||
@@ -0,0 +1,124 @@
|
||||
extends CanvasLayer
|
||||
## Меню паузы. Кладётся внутрь игровой сцены и работает само по себе:
|
||||
## ловит действие «pause», останавливает дерево и переиспользует те же
|
||||
## экраны настроек и сохранений, что и главное меню.
|
||||
##
|
||||
## Слой обрабатывается всегда (process_mode = ALWAYS), поэтому анимации и
|
||||
## ввод продолжают работать, пока остальная игра стоит на паузе.
|
||||
|
||||
## Куда уходим по «В главное меню».
|
||||
@export_file("*.tscn") var main_menu_scene: String = "res://scenes/ui/main_menu.tscn"
|
||||
|
||||
signal opened
|
||||
signal closed
|
||||
|
||||
@onready var _saves: SaveManagerService = get_node("/root/SaveManager")
|
||||
@onready var _root: Control = $Root
|
||||
@onready var _card: Control = $Root/Center/Card
|
||||
@onready var _menu: VBoxContainer = $Root/Center/Card/Body/Menu
|
||||
@onready var _settings_screen: Control = $Root/SettingsScreen
|
||||
@onready var _save_screen: SaveMenuScreen = $Root/SaveScreen
|
||||
|
||||
## Режим курсора до паузы — игра могла держать его захваченным.
|
||||
var _previous_mouse_mode := Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_root.visible = false
|
||||
for button: Button in _menu.get_children():
|
||||
# Мышь и клавиатура делят одну подсветку: наведение = фокус.
|
||||
button.mouse_entered.connect(button.grab_focus)
|
||||
button.pressed.connect(_on_menu_pressed.bind(button.name))
|
||||
_settings_screen.closed.connect(_focus_first_button)
|
||||
_save_screen.closed.connect(_focus_first_button)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# Вложенные экраны сами обрабатывают Esc и гасят событие — сюда оно не
|
||||
# дойдёт, но проверка оставлена на случай другой раскладки для «pause».
|
||||
if _settings_screen.visible or _save_screen.visible:
|
||||
return
|
||||
if not event.is_action_pressed("pause"):
|
||||
return
|
||||
toggle()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func toggle() -> void:
|
||||
if _root.visible:
|
||||
close()
|
||||
else:
|
||||
open()
|
||||
|
||||
|
||||
func open() -> void:
|
||||
# Миниатюру снимаем до показа меню, иначе в слот попадёт само меню.
|
||||
_saves.capture_thumbnail()
|
||||
|
||||
get_tree().paused = true
|
||||
_previous_mouse_mode = Input.mouse_mode
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
_root.modulate.a = 0.0
|
||||
_root.visible = true
|
||||
# Контейнеры раскладываются только после того, как узел стал видимым.
|
||||
await get_tree().process_frame
|
||||
|
||||
_card.pivot_offset = _card.size * 0.5
|
||||
_card.scale = Vector2(0.96, 0.96)
|
||||
|
||||
var tween := create_tween().set_parallel(true).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
|
||||
tween.tween_property(_root, "modulate:a", 1.0, 0.15)
|
||||
tween.tween_property(_card, "scale", Vector2.ONE, 0.2)
|
||||
|
||||
_focus_first_button()
|
||||
opened.emit()
|
||||
|
||||
|
||||
func close() -> void:
|
||||
var tween := create_tween().set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_CUBIC)
|
||||
tween.tween_property(_root, "modulate:a", 0.0, 0.12)
|
||||
await tween.finished
|
||||
|
||||
_root.visible = false
|
||||
get_tree().paused = false
|
||||
Input.mouse_mode = _previous_mouse_mode
|
||||
closed.emit()
|
||||
|
||||
|
||||
func _on_menu_pressed(button_name: StringName) -> void:
|
||||
match String(button_name):
|
||||
"ResumeButton":
|
||||
close()
|
||||
"SaveButton":
|
||||
_save_screen.open(SaveMenuScreen.Mode.SAVE)
|
||||
"LoadButton":
|
||||
_save_screen.open(SaveMenuScreen.Mode.LOAD)
|
||||
"SettingsButton":
|
||||
_settings_screen.open()
|
||||
"MainMenuButton":
|
||||
_return_to_main_menu()
|
||||
"QuitButton":
|
||||
_quit()
|
||||
|
||||
|
||||
func _focus_first_button() -> void:
|
||||
for button: Button in _menu.get_children():
|
||||
if button.visible:
|
||||
button.grab_focus()
|
||||
return
|
||||
|
||||
|
||||
func _return_to_main_menu() -> void:
|
||||
get_tree().paused = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_saves.end_session()
|
||||
get_tree().change_scene_to_file(main_menu_scene)
|
||||
|
||||
|
||||
func _quit() -> void:
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_root, "modulate:a", 0.0, 0.2)
|
||||
await tween.finished
|
||||
get_tree().paused = false
|
||||
get_tree().quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bajc7dq7tn5ik
|
||||
@@ -0,0 +1,220 @@
|
||||
class_name SaveMenuScreen
|
||||
extends Control
|
||||
## Экран слотов сохранения. Один и тот же и для записи, и для чтения —
|
||||
## режим задаётся при открытии: open(Mode.SAVE) из паузы, open(Mode.LOAD)
|
||||
## из главного меню.
|
||||
##
|
||||
## Опасные действия (перезапись и удаление) требуют второго нажатия:
|
||||
## кнопка меняет подпись на «Точно?», строка статуса объясняет, что будет.
|
||||
|
||||
signal closed
|
||||
|
||||
enum Mode { SAVE, LOAD }
|
||||
|
||||
const MUTED := Color("#8fa5ba")
|
||||
const THUMB_SIZE := Vector2(192, 108)
|
||||
|
||||
@onready var _saves: SaveManagerService = get_node("/root/SaveManager")
|
||||
@onready var _card: Control = $Center/Card
|
||||
@onready var _heading: Label = $Center/Card/Body/Heading
|
||||
@onready var _slots_box: VBoxContainer = $Center/Card/Body/Scroll/Slots
|
||||
@onready var _status: Label = $Center/Card/Body/Actions/Status
|
||||
@onready var _back_button: Button = $Center/Card/Body/Actions/BackButton
|
||||
|
||||
var _mode := Mode.LOAD
|
||||
## {"action": "save" | "delete", "index": int} — ждём подтверждения.
|
||||
var _pending := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_back_button.pressed.connect(close)
|
||||
_refresh()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not visible or not event.is_action_pressed("ui_cancel"):
|
||||
return
|
||||
close()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
# --- Открытие / закрытие ----------------------------------------------------
|
||||
|
||||
func open(mode: int) -> void:
|
||||
_mode = mode
|
||||
_pending = {}
|
||||
_heading.text = "СОХРАНЕНИЕ" if mode == Mode.SAVE else "ЗАГРУЗКА"
|
||||
_status.text = "Выберите слот для записи." if mode == Mode.SAVE else "Выберите сохранение."
|
||||
_refresh()
|
||||
|
||||
modulate.a = 0.0
|
||||
visible = true
|
||||
# Контейнеры раскладываются только после того, как узел стал видимым.
|
||||
await get_tree().process_frame
|
||||
|
||||
_card.pivot_offset = _card.size * 0.5
|
||||
_card.scale = Vector2(0.97, 0.97)
|
||||
|
||||
var tween := create_tween().set_parallel(true).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
|
||||
tween.tween_property(self, "modulate:a", 1.0, 0.18)
|
||||
tween.tween_property(_card, "scale", Vector2.ONE, 0.22)
|
||||
|
||||
_focus_first_action()
|
||||
|
||||
|
||||
func close() -> void:
|
||||
var tween := create_tween().set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_CUBIC)
|
||||
tween.tween_property(self, "modulate:a", 0.0, 0.15)
|
||||
await tween.finished
|
||||
visible = false
|
||||
closed.emit()
|
||||
|
||||
|
||||
# --- Список слотов ----------------------------------------------------------
|
||||
|
||||
func _refresh() -> void:
|
||||
for child in _slots_box.get_children():
|
||||
child.queue_free()
|
||||
for info: Dictionary in _saves.list_slots():
|
||||
_slots_box.add_child(_make_row(info))
|
||||
|
||||
|
||||
func _make_row(info: Dictionary) -> PanelContainer:
|
||||
var index: int = info["index"]
|
||||
var exists: bool = info["exists"]
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", _row_style(exists))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 18)
|
||||
panel.add_child(row)
|
||||
|
||||
var thumb := TextureRect.new()
|
||||
thumb.custom_minimum_size = THUMB_SIZE
|
||||
thumb.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
thumb.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||||
thumb.clip_contents = true
|
||||
thumb.texture = info["thumbnail"]
|
||||
row.add_child(thumb)
|
||||
|
||||
var info_box := VBoxContainer.new()
|
||||
info_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
info_box.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
info_box.add_theme_constant_override("separation", 4)
|
||||
row.add_child(info_box)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Слот %d" % (index + 1)
|
||||
title.add_theme_font_size_override("font_size", 22)
|
||||
info_box.add_child(title)
|
||||
|
||||
var meta := Label.new()
|
||||
meta.add_theme_font_size_override("font_size", 16)
|
||||
meta.add_theme_color_override("font_color", MUTED)
|
||||
if exists:
|
||||
meta.text = "%s · %s" % [
|
||||
SaveManagerService.format_timestamp(info["saved_at"]),
|
||||
SaveManagerService.format_playtime(info["playtime"]),
|
||||
]
|
||||
else:
|
||||
meta.text = "пусто"
|
||||
info_box.add_child(meta)
|
||||
|
||||
row.add_child(_make_action_button(index, exists))
|
||||
row.add_child(_make_delete_button(index, exists))
|
||||
return panel
|
||||
|
||||
|
||||
func _make_action_button(index: int, exists: bool) -> Button:
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size.x = 230
|
||||
button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
button.add_theme_font_size_override("font_size", 20)
|
||||
|
||||
if _is_pending("save", index):
|
||||
button.text = "Точно?"
|
||||
elif _mode == Mode.SAVE:
|
||||
button.text = "Перезаписать" if exists else "Сохранить"
|
||||
else:
|
||||
button.text = "Загрузить"
|
||||
button.disabled = not exists
|
||||
|
||||
button.pressed.connect(_on_action_pressed.bind(index))
|
||||
return button
|
||||
|
||||
|
||||
func _make_delete_button(index: int, exists: bool) -> Button:
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size.x = 170
|
||||
button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
button.add_theme_font_size_override("font_size", 20)
|
||||
button.text = "Точно?" if _is_pending("delete", index) else "Удалить"
|
||||
button.disabled = not exists
|
||||
button.pressed.connect(_on_delete_pressed.bind(index))
|
||||
return button
|
||||
|
||||
|
||||
func _row_style(filled: bool) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color("#ffffff0f") if filled else Color("#ffffff05")
|
||||
style.border_color = Color("#ffffff1a")
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(10)
|
||||
style.content_margin_left = 14
|
||||
style.content_margin_right = 14
|
||||
style.content_margin_top = 12
|
||||
style.content_margin_bottom = 12
|
||||
return style
|
||||
|
||||
|
||||
func _focus_first_action() -> void:
|
||||
for row: Node in _slots_box.get_children():
|
||||
for control: Node in row.get_child(0).get_children():
|
||||
if control is Button and not (control as Button).disabled:
|
||||
(control as Button).grab_focus()
|
||||
return
|
||||
_back_button.grab_focus()
|
||||
|
||||
|
||||
# --- Действия ---------------------------------------------------------------
|
||||
|
||||
func _on_action_pressed(index: int) -> void:
|
||||
if _mode == Mode.LOAD:
|
||||
_status.text = "Загрузка слота %d…" % (index + 1)
|
||||
# Не ждём: смена сцены уничтожит этот экран вместе со старым деревом.
|
||||
_saves.load_slot(index)
|
||||
return
|
||||
|
||||
var info := _saves.slot_info(index)
|
||||
if info["exists"] and not _is_pending("save", index):
|
||||
_set_pending("save", index, "Слот %d будет перезаписан — нажмите ещё раз." % (index + 1))
|
||||
return
|
||||
|
||||
_pending = {}
|
||||
if _saves.save_to_slot(index):
|
||||
_status.text = "Сохранено в слот %d." % (index + 1)
|
||||
else:
|
||||
_status.text = "Не удалось сохранить в слот %d." % (index + 1)
|
||||
_refresh()
|
||||
|
||||
|
||||
func _on_delete_pressed(index: int) -> void:
|
||||
if not _is_pending("delete", index):
|
||||
_set_pending("delete", index, "Слот %d будет удалён — нажмите ещё раз." % (index + 1))
|
||||
return
|
||||
|
||||
_pending = {}
|
||||
_saves.delete_slot(index)
|
||||
_status.text = "Слот %d удалён." % (index + 1)
|
||||
_refresh()
|
||||
|
||||
|
||||
func _set_pending(action: String, index: int, message: String) -> void:
|
||||
_pending = {"action": action, "index": index}
|
||||
_status.text = message
|
||||
_refresh()
|
||||
|
||||
|
||||
func _is_pending(action: String, index: int) -> bool:
|
||||
return _pending.get("action", "") == action and _pending.get("index", -1) == index
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtjbpbm5rqb72
|
||||
@@ -0,0 +1,258 @@
|
||||
extends Control
|
||||
## Экран настроек: видео, звук, управление.
|
||||
##
|
||||
## Сам ничего не хранит и не применяет — только показывает состояние
|
||||
## автозагрузки GameSettings и дёргает её сеттеры. Открывается через open(),
|
||||
## закрывается кнопкой «Готово» или Esc и сообщает об этом сигналом closed.
|
||||
|
||||
signal closed
|
||||
|
||||
const ACCENT := Color("#2ee6c5")
|
||||
|
||||
@onready var _settings: GameSettingsService = get_node("/root/GameSettings")
|
||||
|
||||
@onready var _card: Control = $Center/Card
|
||||
@onready var _tabs: TabContainer = $Center/Card/Body/Tabs
|
||||
@onready var _window_mode_option: OptionButton = $Center/Card/Body/Tabs/VideoTab/Rows/WindowModeRow/WindowModeOption
|
||||
@onready var _resolution_option: OptionButton = $Center/Card/Body/Tabs/VideoTab/Rows/ResolutionRow/ResolutionOption
|
||||
@onready var _vsync_toggle: Button = $Center/Card/Body/Tabs/VideoTab/Rows/VsyncRow/VsyncToggle
|
||||
@onready var _fps_option: OptionButton = $Center/Card/Body/Tabs/VideoTab/Rows/FpsRow/FpsOption
|
||||
@onready var _audio_rows: VBoxContainer = $Center/Card/Body/Tabs/AudioTab/Rows
|
||||
@onready var _bindings_box: VBoxContainer = $Center/Card/Body/Tabs/ControlsTab/Wrap/Scroll/Bindings
|
||||
@onready var _bindings_hint: Label = $Center/Card/Body/Tabs/ControlsTab/Wrap/Hint
|
||||
@onready var _reset_button: Button = $Center/Card/Body/Actions/ResetButton
|
||||
@onready var _done_button: Button = $Center/Card/Body/Actions/DoneButton
|
||||
|
||||
## Пока не пусто — ждём клавишу для этого действия.
|
||||
var _capturing_action := ""
|
||||
## Защита от реакции на сигналы во время программного обновления контролов.
|
||||
var _syncing := false
|
||||
var _hint_default := ""
|
||||
var _volume_rows := {}
|
||||
var _binding_buttons := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_hint_default = _bindings_hint.text
|
||||
_name_tabs()
|
||||
_fill_options()
|
||||
_build_audio_rows()
|
||||
_build_binding_rows()
|
||||
|
||||
_window_mode_option.item_selected.connect(_on_window_mode_selected)
|
||||
_resolution_option.item_selected.connect(_on_resolution_selected)
|
||||
_fps_option.item_selected.connect(_on_fps_selected)
|
||||
_vsync_toggle.toggled.connect(_on_vsync_toggled)
|
||||
_reset_button.pressed.connect(_on_reset_pressed)
|
||||
_done_button.pressed.connect(close)
|
||||
|
||||
_sync_from_settings()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if _capturing_action == "":
|
||||
return
|
||||
if not (event is InputEventKey and event.is_pressed() and not event.is_echo()):
|
||||
return
|
||||
|
||||
var key_event := event as InputEventKey
|
||||
var action := _capturing_action
|
||||
_capturing_action = ""
|
||||
|
||||
if key_event.keycode != KEY_ESCAPE:
|
||||
var conflict := _settings.set_binding(action, key_event.keycode)
|
||||
if conflict != "":
|
||||
var label: String = GameSettingsService.ACTION_LABELS[conflict]
|
||||
_bindings_hint.text = "%s: клавиша освобождена, назначьте новую." % label
|
||||
else:
|
||||
_bindings_hint.text = _hint_default
|
||||
|
||||
_refresh_bindings()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not visible or not event.is_action_pressed("ui_cancel"):
|
||||
return
|
||||
close()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
# --- Открытие / закрытие ----------------------------------------------------
|
||||
|
||||
func open() -> void:
|
||||
visible = true
|
||||
_sync_from_settings()
|
||||
modulate.a = 0.0
|
||||
# Контейнеры раскладываются только после того, как узел стал видимым.
|
||||
await get_tree().process_frame
|
||||
|
||||
_card.pivot_offset = _card.size * 0.5
|
||||
_card.scale = Vector2(0.97, 0.97)
|
||||
|
||||
var tween := create_tween().set_parallel(true).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
|
||||
tween.tween_property(self, "modulate:a", 1.0, 0.18)
|
||||
tween.tween_property(_card, "scale", Vector2.ONE, 0.22)
|
||||
|
||||
_tabs.get_tab_bar().grab_focus()
|
||||
|
||||
|
||||
func close() -> void:
|
||||
# Esc во время ожидания клавиши сначала отменяет назначение.
|
||||
if _capturing_action != "":
|
||||
_capturing_action = ""
|
||||
_bindings_hint.text = _hint_default
|
||||
_refresh_bindings()
|
||||
return
|
||||
|
||||
var tween := create_tween().set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_CUBIC)
|
||||
tween.tween_property(self, "modulate:a", 0.0, 0.15)
|
||||
await tween.finished
|
||||
visible = false
|
||||
closed.emit()
|
||||
|
||||
|
||||
# --- Построение вкладок -----------------------------------------------------
|
||||
|
||||
func _name_tabs() -> void:
|
||||
_tabs.set_tab_title(0, "Видео")
|
||||
_tabs.set_tab_title(1, "Звук")
|
||||
_tabs.set_tab_title(2, "Управление")
|
||||
|
||||
|
||||
func _fill_options() -> void:
|
||||
for label: String in GameSettingsService.WINDOW_MODE_LABELS:
|
||||
_window_mode_option.add_item(label)
|
||||
|
||||
for resolution: Vector2i in GameSettingsService.RESOLUTIONS:
|
||||
_resolution_option.add_item("%d × %d" % [resolution.x, resolution.y])
|
||||
|
||||
for limit: int in GameSettingsService.FPS_LIMITS:
|
||||
_fps_option.add_item("Без ограничения" if limit == 0 else str(limit))
|
||||
|
||||
|
||||
func _build_audio_rows() -> void:
|
||||
for bus_name: String in GameSettingsService.AUDIO_BUSES:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 20)
|
||||
|
||||
var caption := Label.new()
|
||||
caption.text = GameSettingsService.AUDIO_BUSES[bus_name]
|
||||
caption.custom_minimum_size.x = 300
|
||||
row.add_child(caption)
|
||||
|
||||
var slider := HSlider.new()
|
||||
slider.min_value = 0.0
|
||||
slider.max_value = 1.0
|
||||
slider.step = 0.01
|
||||
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
slider.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
row.add_child(slider)
|
||||
|
||||
var value_label := Label.new()
|
||||
value_label.custom_minimum_size.x = 90
|
||||
value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
value_label.add_theme_color_override("font_color", ACCENT)
|
||||
row.add_child(value_label)
|
||||
|
||||
slider.value_changed.connect(_on_volume_changed.bind(bus_name))
|
||||
_volume_rows[bus_name] = {"slider": slider, "label": value_label}
|
||||
_audio_rows.add_child(row)
|
||||
|
||||
|
||||
func _build_binding_rows() -> void:
|
||||
for action: String in GameSettingsService.ACTION_LABELS:
|
||||
var row := HBoxContainer.new()
|
||||
|
||||
var caption := Label.new()
|
||||
caption.text = GameSettingsService.ACTION_LABELS[action]
|
||||
caption.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(caption)
|
||||
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size.x = 260
|
||||
button.add_theme_font_size_override("font_size", 20)
|
||||
button.pressed.connect(_start_capture.bind(action))
|
||||
row.add_child(button)
|
||||
|
||||
_binding_buttons[action] = button
|
||||
_bindings_box.add_child(row)
|
||||
|
||||
|
||||
# --- Синхронизация UI -------------------------------------------------------
|
||||
|
||||
func _sync_from_settings() -> void:
|
||||
_syncing = true
|
||||
|
||||
_window_mode_option.selected = _settings.window_mode
|
||||
_resolution_option.selected = GameSettingsService.RESOLUTIONS.find(_settings.resolution)
|
||||
_resolution_option.disabled = _settings.window_mode != GameSettingsService.WindowMode.WINDOWED
|
||||
_fps_option.selected = GameSettingsService.FPS_LIMITS.find(_settings.fps_limit)
|
||||
_vsync_toggle.button_pressed = _settings.vsync
|
||||
_vsync_toggle.text = "Вкл" if _settings.vsync else "Выкл"
|
||||
|
||||
for bus_name: String in _volume_rows:
|
||||
var value: float = _settings.volumes[bus_name]
|
||||
_volume_rows[bus_name]["slider"].value = value
|
||||
_volume_rows[bus_name]["label"].text = "%d%%" % roundi(value * 100.0)
|
||||
|
||||
_refresh_bindings()
|
||||
_syncing = false
|
||||
|
||||
|
||||
func _refresh_bindings() -> void:
|
||||
for action: String in _binding_buttons:
|
||||
var button: Button = _binding_buttons[action]
|
||||
if action == _capturing_action:
|
||||
button.text = "Нажмите клавишу…"
|
||||
else:
|
||||
button.text = GameSettingsService.key_name(int(_settings.bindings.get(action, KEY_NONE)))
|
||||
|
||||
|
||||
# --- Обработчики ------------------------------------------------------------
|
||||
|
||||
func _on_window_mode_selected(index: int) -> void:
|
||||
if _syncing:
|
||||
return
|
||||
_settings.set_window_mode(index)
|
||||
_resolution_option.disabled = index != GameSettingsService.WindowMode.WINDOWED
|
||||
|
||||
|
||||
func _on_resolution_selected(index: int) -> void:
|
||||
if _syncing:
|
||||
return
|
||||
_settings.set_resolution(GameSettingsService.RESOLUTIONS[index])
|
||||
|
||||
|
||||
func _on_fps_selected(index: int) -> void:
|
||||
if _syncing:
|
||||
return
|
||||
_settings.set_fps_limit(GameSettingsService.FPS_LIMITS[index])
|
||||
|
||||
|
||||
func _on_vsync_toggled(enabled: bool) -> void:
|
||||
_vsync_toggle.text = "Вкл" if enabled else "Выкл"
|
||||
if _syncing:
|
||||
return
|
||||
_settings.set_vsync(enabled)
|
||||
|
||||
|
||||
func _on_volume_changed(value: float, bus_name: String) -> void:
|
||||
_volume_rows[bus_name]["label"].text = "%d%%" % roundi(value * 100.0)
|
||||
if _syncing:
|
||||
return
|
||||
_settings.set_volume(bus_name, value)
|
||||
|
||||
|
||||
func _on_reset_pressed() -> void:
|
||||
_settings.reset_to_defaults()
|
||||
_bindings_hint.text = _hint_default
|
||||
_sync_from_settings()
|
||||
|
||||
|
||||
func _start_capture(action: String) -> void:
|
||||
if _capturing_action != "":
|
||||
return
|
||||
_capturing_action = action
|
||||
_bindings_hint.text = _hint_default
|
||||
_refresh_bindings()
|
||||
@@ -0,0 +1 @@
|
||||
uid://jkhpwi3ngait
|
||||
Reference in New Issue
Block a user