class_name SettingsPanel extends CanvasLayer ## Settings: mouse look sensitivity and HUD font scale, persisted through the ## `Save` autoload into `user://settings.json`. ## ## Emits `applied(settings)` so the game manager can push values into the guard ## and HUD as soon as the player confirms. const InputBindings := preload("res://src/core/input_bindings.gd") const UiArt := preload("res://src/game/ui/art.gd") signal applied(settings: Dictionary) signal closed() var _root: Control = null var _sensitivity: HSlider = null var _font_scale: HSlider = null var _sensitivity_value: Label = null var _font_scale_value: Label = null var _invert_y: CheckButton = null var _head_bob: CheckButton = null var _bind_buttons := {} var _listening_action := "" var settings: Dictionary = {} func _ready() -> void: layer = 22 _build() hide() func _init() -> void: layer = 22 _build() hide() func _build() -> void: _root = Control.new() _root.name = "SettingsRoot" _root.set_anchors_preset(Control.PRESET_FULL_RECT) _root.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_root) var art := UiArt.new() if not art.add_screen_backdrop(_root, "settings_panel", Rect2(0.117, 0.049, 0.703, 0.895)): var bg := ColorRect.new() bg.color = Color(0.02, 0.03, 0.05, 0.9) bg.set_anchors_preset(Control.PRESET_FULL_RECT) bg.mouse_filter = Control.MOUSE_FILTER_IGNORE _root.add_child(bg) var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) _root.add_child(center) var column := VBoxContainer.new() column.custom_minimum_size = Vector2(640, 0) column.add_theme_constant_override("separation", 12) center.add_child(column) var title := Label.new() title.text = "Settings" title.add_theme_font_size_override("font_size", 32) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER column.add_child(title) var sens_row := VBoxContainer.new() column.add_child(sens_row) _sensitivity_value = _add_label_row(sens_row, "Mouse look sensitivity") _sensitivity = _add_slider(sens_row, 0.0005, 0.0060, 0.0001) _sensitivity.value_changed.connect(_on_sensitivity_changed) var font_row := VBoxContainer.new() column.add_child(font_row) _font_scale_value = _add_label_row(font_row, "HUD font scale") _font_scale = _add_slider(font_row, 0.75, 1.75, 0.05) _font_scale.value_changed.connect(_on_font_scale_changed) _invert_y = CheckButton.new() _invert_y.text = "Invert vertical look" _invert_y.toggled.connect(_on_invert_y_toggled) column.add_child(_invert_y) _head_bob = CheckButton.new() _head_bob.text = "Head bob and camera motion" _head_bob.toggled.connect(_on_head_bob_toggled) column.add_child(_head_bob) var controls_title := Label.new() controls_title.text = "Controls (click a binding, then press a key or pad button)" controls_title.add_theme_font_size_override("font_size", 13) controls_title.modulate = Color(0.75, 0.8, 0.9) column.add_child(controls_title) var grid := GridContainer.new() grid.columns = 4 grid.add_theme_constant_override("h_separation", 10) grid.add_theme_constant_override("v_separation", 6) column.add_child(grid) for action in InputBindings.REMAPPABLE: var label := Label.new() label.text = str(action).replace("_", " ").capitalize() label.size_flags_horizontal = Control.SIZE_EXPAND_FILL grid.add_child(label) var button := Button.new() button.custom_minimum_size = Vector2(130, 0) button.pressed.connect(_on_rebind.bind(action)) grid.add_child(button) _bind_buttons[action] = button var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 12) column.add_child(row) var save_button := Button.new() save_button.name = "ApplyButton" save_button.text = "Apply" save_button.pressed.connect(_on_apply) row.add_child(save_button) var cancel_button := Button.new() cancel_button.name = "CancelButton" cancel_button.text = "Cancel" cancel_button.pressed.connect(_on_cancel) row.add_child(cancel_button) func _add_label_row(parent: Control, text: String) -> Label: var row := HBoxContainer.new() parent.add_child(row) var label := Label.new() label.text = text label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(label) var value := Label.new() value.add_theme_color_override("font_color", Color(0.75, 0.8, 0.9)) row.add_child(value) return value func _add_slider(parent: Control, min_v: float, max_v: float, step: float) -> HSlider: var slider := HSlider.new() slider.min_value = min_v slider.max_value = max_v slider.step = step slider.custom_minimum_size = Vector2(420, 24) parent.add_child(slider) return slider func open(current: Dictionary) -> void: settings = current.duplicate(true) var sens: float = float(settings.get("look_sensitivity", 0.0022)) var scale: float = float(settings.get("hud_font_scale", 1.0)) if _sensitivity: _sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value)) if _font_scale: _font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value)) if _invert_y: _invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false))) if _head_bob: _head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true))) _listening_action = "" _refresh_bind_buttons() _sync_labels() visible = true _root.visible = true Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) func close() -> void: visible = false _root.visible = false func _sync_labels() -> void: if _sensitivity_value and _sensitivity: _sensitivity_value.text = "%.4f" % _sensitivity.value if _font_scale_value and _font_scale: _font_scale_value.text = "%.2fx" % _font_scale.value func _on_sensitivity_changed(value: float) -> void: settings["look_sensitivity"] = value _sync_labels() func _on_font_scale_changed(value: float) -> void: settings["hud_font_scale"] = value _sync_labels() func _on_invert_y_toggled(pressed: bool) -> void: settings["invert_y"] = pressed func _on_head_bob_toggled(pressed: bool) -> void: settings["head_bob"] = pressed func _refresh_bind_buttons() -> void: var remaps: Dictionary = settings.get("input_bindings", {}) for action in _bind_buttons.keys(): var button: Button = _bind_buttons[action] var descriptor: Dictionary = remaps.get(action, {}) if descriptor.is_empty(): descriptor = InputBindings.current_descriptor(action) button.text = InputBindings.label_for(descriptor) func _on_rebind(action: String) -> void: _listening_action = action var button: Button = _bind_buttons.get(action) if button: button.text = "Press…" if button.is_inside_tree(): button.grab_focus() func _apply_remap(event: InputEvent) -> bool: if _listening_action == "": return false var descriptor := InputBindings.descriptor_from_event(event) if descriptor.is_empty(): return false var remaps: Dictionary = settings.get("input_bindings", {}) remaps[_listening_action] = descriptor settings["input_bindings"] = remaps _listening_action = "" _refresh_bind_buttons() return true func _on_apply() -> void: if Save: Save.save_settings(settings) applied.emit(settings) close() closed.emit() func _on_cancel() -> void: close() closed.emit() func _unhandled_input(event: InputEvent) -> void: if not visible: return if _listening_action != "": var pressed: bool = (event is InputEventKey and event.pressed and not event.echo) \ or (event is InputEventJoypadButton and event.pressed) if pressed and _apply_remap(event): get_viewport().set_input_as_handled() return if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: _on_cancel() get_viewport().set_input_as_handled()