first commit

This commit is contained in:
Leonid Pershin
2026-08-10 20:39:06 +03:00
commit f16f60445c
281 changed files with 43576 additions and 0 deletions
+285
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dt0wp3ov76ad8
+243
View File
@@ -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 = {}
+1
View File
@@ -0,0 +1 @@
uid://c10fe0cdsjjc0