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)