extends CanvasLayer var bg: ColorRect var main_vbox: VBoxContainer var resume_btn: Button var respawn_btn: Button var loadouts_btn: Button var settings_btn: Button var return_btn: Button var exit_btn: Button # Character preview integrated into the loadout screen. var character_desc: Label var _preview_viewport: SubViewport var _preview_model: SkinnedPlayerModel var _preview_pivot: Node3D var _preview_id: String = "" # Loadout UI var loadout_editor: Control var loadout_list_vbox: VBoxContainer var edit_panel: PanelContainer var name_edit: LineEdit var primary1_opt: OptionButton var primary2_opt: OptionButton var special_opt: OptionButton var melee_opt: OptionButton var skin_opt: OptionButton var loadout_summary: Label var edit_title_label: Label var save_loadout_btn: Button var back_to_main_btn: Button var editing_index: int = -1 # Settings UI var settings_editor: Control var settings_back_btn: Button var _world_fov_slider: HSlider var _world_fov_input: LineEdit var _debug_ui_checkbox: CheckButton var _mouse_sens_slider: HSlider var _mouse_sens_input: LineEdit var _ads_sens_slider: HSlider var _ads_sens_input: LineEdit var _display_mode_opt: OptionButton var _resolution_opt: OptionButton var _fps_slider: HSlider var _fps_input: LineEdit var _show_fps_checkbox: CheckButton var _show_movement_speed_checkbox: CheckButton var _vol_master_slider: HSlider var _vol_master_input: LineEdit var _vol_sfx_slider: HSlider var _vol_sfx_input: LineEdit var _vol_music_slider: HSlider var _vol_music_input: LineEdit var _vol_wind_slider: HSlider var _vol_wind_input: LineEdit var keybind_vbox: VBoxContainer var rebind_overlay: ColorRect var rebind_label: Label var waiting_for_input_action: String = "" var quit_dialog: ConfirmationDialog var return_dialog: ConfirmationDialog func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS layer = 100 # Ensure it's on top visible = false # This screen is an autoload, so it can be the FIRST thing the player sees # styled — opened over a level that never loaded the HUD, it used to render # in Godot's default grey because it relied on some other screen having # applied the theme. apply_global is idempotent; just call it. UITheme.apply_global(get_tree()) _build_ui() _connect_signals() UITheme.wire_sounds(self) func _input(event: InputEvent) -> void: if waiting_for_input_action != "": if event is InputEventKey or event is InputEventMouseButton: if event.is_pressed(): SettingsManager.rebind_action(waiting_for_input_action, event) waiting_for_input_action = "" rebind_overlay.visible = false _populate_keybindings() # Consume event so it doesn't trigger anything else get_viewport().set_input_as_handled() return if event.is_action_pressed("ui_cancel"): # Don't open the pause menu if we are already in the main menu if get_tree().current_scene and get_tree().current_scene.name == "MainMenu": return if visible: _resume() else: _pause() func _pause() -> void: visible = true Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) _show_main_menu() func _resume() -> void: visible = false Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) func _respawn() -> void: _resume() # Unpause # Find local player using their unique multiplayer ID var local_player: Node3D = null var pid = multiplayer.get_unique_id() if get_tree().current_scene: local_player = get_tree().current_scene.get_node_or_null(str(pid)) if not local_player: var spawner = get_tree().current_scene.get_node_or_null("PlayerSpawner") if spawner: local_player = spawner.get_node_or_null(str(pid)) if local_player and local_player.has_method("rpc_request_respawn"): local_player.rpc_request_respawn.rpc_id(1) func _build_ui() -> void: bg = ColorRect.new() bg.color = Color(0, 0, 0, 0.7) bg.set_anchors_preset(Control.PRESET_FULL_RECT) # Assigned here as well as on the root Window, because this screen hangs off # a CanvasLayer. A Control inherits its theme from its nearest Control # ANCESTOR, and a CanvasLayer is not one — so the chain breaks here and # everything below fell back to Godot's default grey. It was not obvious, # because the parts built with UITheme.title() carry their own overrides and # looked correct next to a list and a button that did not. bg.theme = UITheme.build() add_child(bg) # ── Main Menu ── var main_center = CenterContainer.new() main_center.set_anchors_preset(Control.PRESET_FULL_RECT) bg.add_child(main_center) main_vbox = VBoxContainer.new() main_vbox.add_theme_constant_override("separation", 20) main_center.add_child(main_vbox) var title = UITheme.title("PAUSED", 56) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER main_vbox.add_child(title) main_vbox.add_child(UITheme.divider(0.62)) resume_btn = Button.new() resume_btn.text = "Resume" resume_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(resume_btn) respawn_btn = Button.new() respawn_btn.text = "Respawn" respawn_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(respawn_btn) loadouts_btn = Button.new() loadouts_btn.text = "Loadouts" loadouts_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(loadouts_btn) settings_btn = Button.new() settings_btn.text = "Settings" settings_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(settings_btn) return_btn = Button.new() return_btn.text = "Main Menu" return_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(return_btn) exit_btn = Button.new() exit_btn.text = "Exit Game" exit_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(exit_btn) # ── Loadout Editor Root ── loadout_editor = Control.new() loadout_editor.set_anchors_preset(Control.PRESET_FULL_RECT) bg.add_child(loadout_editor) var screen_fill := ColorRect.new() screen_fill.color = Color(0.025, 0.030, 0.060, 0.96) screen_fill.set_anchors_preset(Control.PRESET_FULL_RECT) loadout_editor.add_child(screen_fill) var margin := MarginContainer.new() margin.set_anchors_preset(Control.PRESET_FULL_RECT) for side in ["left", "right"]: margin.add_theme_constant_override("margin_" + side, 42) margin.add_theme_constant_override("margin_top", 30) margin.add_theme_constant_override("margin_bottom", 30) loadout_editor.add_child(margin) var screen_vbox := VBoxContainer.new() screen_vbox.add_theme_constant_override("separation", 22) margin.add_child(screen_vbox) var header := HBoxContainer.new() screen_vbox.add_child(header) header.add_child(UITheme.title("LOADOUTS", 54)) var header_spacer := Control.new() header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(header_spacer) back_to_main_btn = Button.new() back_to_main_btn.text = "Back" back_to_main_btn.custom_minimum_size = Vector2(170, 46) header.add_child(back_to_main_btn) screen_vbox.add_child(UITheme.divider(0.82)) var editor_hbox := HBoxContainer.new() editor_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL editor_hbox.add_theme_constant_override("separation", 22) screen_vbox.add_child(editor_hbox) var left_vbox := VBoxContainer.new() left_vbox.custom_minimum_size = Vector2(380, 0) left_vbox.add_theme_constant_override("separation", 12) editor_hbox.add_child(left_vbox) left_vbox.add_child(UITheme.heading("YOUR LOADOUTS", 24)) var loadout_scroll := ScrollContainer.new() loadout_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL left_vbox.add_child(loadout_scroll) loadout_list_vbox = VBoxContainer.new() loadout_list_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL loadout_list_vbox.add_theme_constant_override("separation", 10) loadout_scroll.add_child(loadout_list_vbox) edit_panel = UITheme.card() edit_panel.custom_minimum_size = Vector2(500, 0) edit_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL editor_hbox.add_child(edit_panel) var edit_margin := MarginContainer.new() edit_margin.add_theme_constant_override("margin_left", 24) edit_margin.add_theme_constant_override("margin_right", 24) edit_margin.add_theme_constant_override("margin_top", 20) edit_margin.add_theme_constant_override("margin_bottom", 20) edit_panel.add_child(edit_margin) var edit_vbox := VBoxContainer.new() edit_vbox.add_theme_constant_override("separation", 12) edit_margin.add_child(edit_vbox) edit_title_label = UITheme.heading("EDIT LOADOUT", 28) edit_vbox.add_child(edit_title_label) edit_vbox.add_child(UITheme.divider(0.72)) edit_vbox.add_child(UITheme.caption("LOADOUT NAME", 16)) name_edit = LineEdit.new() name_edit.placeholder_text = "Loadout Name" name_edit.custom_minimum_size = Vector2(0, 44) edit_vbox.add_child(name_edit) var options_grid := GridContainer.new() options_grid.columns = 2 options_grid.add_theme_constant_override("h_separation", 16) options_grid.add_theme_constant_override("v_separation", 12) edit_vbox.add_child(options_grid) options_grid.add_child(_create_label("Character / Skin")) skin_opt = OptionButton.new() skin_opt.custom_minimum_size = Vector2(285, 42) options_grid.add_child(skin_opt) options_grid.add_child(_create_label("Primary 1")) primary1_opt = OptionButton.new() primary1_opt.custom_minimum_size = Vector2(285, 42) options_grid.add_child(primary1_opt) options_grid.add_child(_create_label("Primary 2")) primary2_opt = OptionButton.new() primary2_opt.custom_minimum_size = Vector2(285, 42) options_grid.add_child(primary2_opt) options_grid.add_child(_create_label("Special")) special_opt = OptionButton.new() special_opt.custom_minimum_size = Vector2(285, 42) options_grid.add_child(special_opt) options_grid.add_child(_create_label("Melee")) melee_opt = OptionButton.new() melee_opt.custom_minimum_size = Vector2(285, 42) options_grid.add_child(melee_opt) edit_vbox.add_child(UITheme.caption("SELECTED ITEMS", 16)) loadout_summary = Label.new() loadout_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART loadout_summary.custom_minimum_size = Vector2(0, 92) loadout_summary.add_theme_font_size_override("font_size", 18) edit_vbox.add_child(loadout_summary) var edit_spacer := Control.new() edit_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL edit_vbox.add_child(edit_spacer) save_loadout_btn = Button.new() save_loadout_btn.text = "Save Loadout" save_loadout_btn.custom_minimum_size = Vector2(0, 48) edit_vbox.add_child(save_loadout_btn) var preview_card := UITheme.card() preview_card.custom_minimum_size = Vector2(440, 0) editor_hbox.add_child(preview_card) _build_loadout_character_preview(preview_card) # ── Settings Editor Root ── settings_editor = Control.new() settings_editor.set_anchors_preset(Control.PRESET_FULL_RECT) bg.add_child(settings_editor) var settings_center = CenterContainer.new() settings_center.set_anchors_preset(Control.PRESET_FULL_RECT) settings_editor.add_child(settings_center) var settings_vbox = VBoxContainer.new() settings_vbox.custom_minimum_size = Vector2(600, 400) settings_center.add_child(settings_vbox) settings_back_btn = Button.new() settings_back_btn.text = "Back to Menu" settings_vbox.add_child(settings_back_btn) var tab_container = TabContainer.new() tab_container.size_flags_vertical = Control.SIZE_EXPAND_FILL settings_vbox.add_child(tab_container) # General Tab var general_tab = VBoxContainer.new() general_tab.name = "General" tab_container.add_child(general_tab) var user_hbox = HBoxContainer.new() general_tab.add_child(user_hbox) var user_lbl = Label.new() user_lbl.text = "Username:" user_lbl.custom_minimum_size = Vector2(150, 0) user_hbox.add_child(user_lbl) var user_input = LineEdit.new() user_input.text = SettingsManager.username user_input.custom_minimum_size = Vector2(200, 0) user_hbox.add_child(user_input) var user_save = Button.new() user_save.text = "Save" user_save.pressed.connect(func(): var final_name = user_input.text.strip_edges() user_input.text = final_name SettingsManager.username = final_name SettingsManager._save_settings() var nm = get_node_or_null("/root/NetworkManager") if nm: if nm.multiplayer.has_multiplayer_peer() and not nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer: nm.update_username.rpc_id(1, final_name) else: nm.update_username(final_name) ) user_hbox.add_child(user_save) var fov_hbox = HBoxContainer.new() general_tab.add_child(fov_hbox) var fov_lbl = Label.new() fov_lbl.text = "World FOV" fov_lbl.custom_minimum_size = Vector2(150, 0) fov_hbox.add_child(fov_lbl) _world_fov_slider = HSlider.new() _world_fov_slider.min_value = 70.0 _world_fov_slider.max_value = 120.0 _world_fov_slider.step = 1.0 _world_fov_slider.value = SettingsManager.world_fov _world_fov_slider.custom_minimum_size = Vector2(200, 0) _world_fov_slider.size_flags_vertical = Control.SIZE_SHRINK_CENTER fov_hbox.add_child(_world_fov_slider) _world_fov_input = LineEdit.new() _world_fov_input.text = str(round(SettingsManager.world_fov)) _world_fov_input.custom_minimum_size = Vector2(60, 0) fov_hbox.add_child(_world_fov_input) _world_fov_slider.value_changed.connect(func(value: float): _world_fov_input.text = str(round(value)) SettingsManager.world_fov = value SettingsManager._save_settings() ) _world_fov_input.text_submitted.connect(func(new_text: String): var val = new_text.to_float() val = clampf(val, 70.0, 120.0) _world_fov_slider.value = val _world_fov_input.text = str(round(val)) SettingsManager.world_fov = val SettingsManager._save_settings() ) var spacer_gen = Control.new() spacer_gen.size_flags_vertical = Control.SIZE_EXPAND_FILL general_tab.add_child(spacer_gen) var reset_gen_btn = Button.new() reset_gen_btn.text = "Reset to Defaults" reset_gen_btn.pressed.connect(func(): _world_fov_slider.value = 90.0 SettingsManager.world_fov = 90.0 SettingsManager._save_settings() ) general_tab.add_child(reset_gen_btn) # Video Tab var video_tab = VBoxContainer.new() video_tab.name = "Video" video_tab.add_theme_constant_override("separation", 10) tab_container.add_child(video_tab) var mode_hbox = HBoxContainer.new() video_tab.add_child(mode_hbox) var mode_lbl = Label.new() mode_lbl.text = "Display Mode" mode_lbl.custom_minimum_size = Vector2(150, 0) mode_hbox.add_child(mode_lbl) _display_mode_opt = OptionButton.new() _display_mode_opt.add_item("Windowed", DisplayServer.WINDOW_MODE_WINDOWED) _display_mode_opt.add_item("Borderless Fullscreen", DisplayServer.WINDOW_MODE_FULLSCREEN) _display_mode_opt.add_item("Exclusive Fullscreen", DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN) _display_mode_opt.item_selected.connect(func(index: int): SettingsManager.display_mode = _display_mode_opt.get_item_id(index) SettingsManager._save_settings() SettingsManager.apply_video_settings() ) mode_hbox.add_child(_display_mode_opt) var res_hbox = HBoxContainer.new() video_tab.add_child(res_hbox) var res_lbl = Label.new() res_lbl.text = "Resolution" res_lbl.custom_minimum_size = Vector2(150, 0) res_hbox.add_child(res_lbl) _resolution_opt = OptionButton.new() var res_names = ["640x480 (480p)", "1280x720 (720p)", "1920x1080 (1080p)", "2560x1080 (UW-1080p)", "2560x1440 (1440p)", "3440x1440 (UW-1440p)", "3840x2160 (4K)"] for i in range(res_names.size()): _resolution_opt.add_item(res_names[i], i) _resolution_opt.item_selected.connect(func(index: int): SettingsManager.resolution_index = index SettingsManager._save_settings() SettingsManager.apply_video_settings() ) res_hbox.add_child(_resolution_opt) var fps_hbox = HBoxContainer.new() video_tab.add_child(fps_hbox) var fps_lbl = Label.new() fps_lbl.text = "FPS Cap" fps_lbl.custom_minimum_size = Vector2(150, 0) fps_hbox.add_child(fps_lbl) _fps_slider = HSlider.new() _fps_slider.min_value = 30.0 _fps_slider.max_value = 361.0 _fps_slider.step = 1.0 _fps_slider.custom_minimum_size = Vector2(200, 0) _fps_slider.size_flags_vertical = Control.SIZE_SHRINK_CENTER fps_hbox.add_child(_fps_slider) _fps_input = LineEdit.new() _fps_input.custom_minimum_size = Vector2(80, 0) fps_hbox.add_child(_fps_input) _fps_slider.value_changed.connect(func(value: float): if value > 360.0: _fps_input.text = "Uncapped" SettingsManager.fps_cap = 0 else: _fps_input.text = str(int(value)) SettingsManager.fps_cap = int(value) SettingsManager._save_settings() SettingsManager.apply_video_settings() ) _fps_input.text_submitted.connect(func(new_text: String): var val = new_text.to_int() if val > 360 or val <= 0: _fps_slider.value = 361.0 _fps_input.text = "Uncapped" SettingsManager.fps_cap = 0 else: val = max(30, val) _fps_slider.value = float(val) _fps_input.text = str(val) SettingsManager.fps_cap = val SettingsManager._save_settings() SettingsManager.apply_video_settings() ) var spacer_vid = Control.new() spacer_vid.size_flags_vertical = Control.SIZE_EXPAND_FILL video_tab.add_child(spacer_vid) var reset_vid_btn = Button.new() reset_vid_btn.text = "Reset to Defaults" reset_vid_btn.pressed.connect(func(): SettingsManager.display_mode = DisplayServer.WINDOW_MODE_WINDOWED SettingsManager.resolution_index = 2 SettingsManager.fps_cap = 0 SettingsManager._save_settings() SettingsManager.apply_video_settings() _populate_video_ui() ) video_tab.add_child(reset_vid_btn) # Audio Tab var audio_tab = VBoxContainer.new() audio_tab.name = "Audio" audio_tab.add_theme_constant_override("separation", 10) tab_container.add_child(audio_tab) # Master var master_arr = _build_vol_control(audio_tab, "Master Volume") _vol_master_slider = master_arr[0] _vol_master_input = master_arr[1] _vol_master_slider.value_changed.connect(func(v): _on_vol_changed("vol_master", v, _vol_master_input)) _vol_master_input.text_submitted.connect(func(t): _on_vol_text_changed("vol_master", t, _vol_master_slider, _vol_master_input)) # SFX var sfx_arr = _build_vol_control(audio_tab, "Sound Effects") _vol_sfx_slider = sfx_arr[0] _vol_sfx_input = sfx_arr[1] _vol_sfx_slider.value_changed.connect(func(v): _on_vol_changed("vol_sfx", v, _vol_sfx_input)) _vol_sfx_input.text_submitted.connect(func(t): _on_vol_text_changed("vol_sfx", t, _vol_sfx_slider, _vol_sfx_input)) # Music var music_arr = _build_vol_control(audio_tab, "Music") _vol_music_slider = music_arr[0] _vol_music_input = music_arr[1] _vol_music_slider.value_changed.connect(func(v): _on_vol_changed("vol_music", v, _vol_music_input)) _vol_music_input.text_submitted.connect(func(t): _on_vol_text_changed("vol_music", t, _vol_music_slider, _vol_music_input)) # Wind var wind_arr = _build_vol_control(audio_tab, "Wind") _vol_wind_slider = wind_arr[0] _vol_wind_input = wind_arr[1] _vol_wind_slider.value_changed.connect(func(v): _on_vol_changed("vol_wind", v, _vol_wind_input)) _vol_wind_input.text_submitted.connect(func(t): _on_vol_text_changed("vol_wind", t, _vol_wind_slider, _vol_wind_input)) var spacer_aud = Control.new() spacer_aud.size_flags_vertical = Control.SIZE_EXPAND_FILL audio_tab.add_child(spacer_aud) var reset_aud_btn = Button.new() reset_aud_btn.text = "Reset to Defaults" reset_aud_btn.pressed.connect(func(): SettingsManager.vol_master = 100.0 SettingsManager.vol_sfx = 100.0 SettingsManager.vol_music = 100.0 SettingsManager.vol_wind = 100.0 SettingsManager._save_settings() SettingsManager.apply_audio_settings() _populate_audio_ui() ) audio_tab.add_child(reset_aud_btn) # Mouse Tab var mouse_tab = VBoxContainer.new() mouse_tab.name = "Mouse" mouse_tab.add_theme_constant_override("separation", 10) tab_container.add_child(mouse_tab) var sens_hbox = HBoxContainer.new() mouse_tab.add_child(sens_hbox) var sens_title = Label.new() sens_title.text = "Sensitivity" sens_title.custom_minimum_size = Vector2(150, 0) sens_hbox.add_child(sens_title) _mouse_sens_slider = HSlider.new() _mouse_sens_slider.min_value = 1.0 _mouse_sens_slider.max_value = 100.0 _mouse_sens_slider.step = 1.0 _mouse_sens_slider.value = SettingsManager.mouse_sensitivity * 10000.0 _mouse_sens_slider.custom_minimum_size = Vector2(200, 0) _mouse_sens_slider.size_flags_vertical = Control.SIZE_SHRINK_CENTER sens_hbox.add_child(_mouse_sens_slider) _mouse_sens_input = LineEdit.new() _mouse_sens_input.text = str(round(SettingsManager.mouse_sensitivity * 10000.0)) _mouse_sens_input.custom_minimum_size = Vector2(60, 0) sens_hbox.add_child(_mouse_sens_input) _mouse_sens_slider.value_changed.connect(func(value: float): _mouse_sens_input.text = str(round(value)) SettingsManager.mouse_sensitivity = value / 10000.0 SettingsManager._save_settings() ) _mouse_sens_input.text_submitted.connect(func(new_text: String): var val = new_text.to_float() val = clampf(val, 1.0, 100.0) _mouse_sens_slider.value = val _mouse_sens_input.text = str(round(val)) SettingsManager.mouse_sensitivity = val / 10000.0 SettingsManager._save_settings() ) var ads_sens_hbox = HBoxContainer.new() mouse_tab.add_child(ads_sens_hbox) var ads_sens_title = Label.new() ads_sens_title.text = "ADS Sensitivity" ads_sens_title.custom_minimum_size = Vector2(150, 0) ads_sens_hbox.add_child(ads_sens_title) _ads_sens_slider = HSlider.new() _ads_sens_slider.min_value = 1.0 _ads_sens_slider.max_value = 100.0 _ads_sens_slider.step = 1.0 _ads_sens_slider.value = SettingsManager.ads_sensitivity * 10000.0 _ads_sens_slider.custom_minimum_size = Vector2(200, 0) _ads_sens_slider.size_flags_vertical = Control.SIZE_SHRINK_CENTER ads_sens_hbox.add_child(_ads_sens_slider) _ads_sens_input = LineEdit.new() _ads_sens_input.text = str(round(SettingsManager.ads_sensitivity * 10000.0)) _ads_sens_input.custom_minimum_size = Vector2(60, 0) ads_sens_hbox.add_child(_ads_sens_input) _ads_sens_slider.value_changed.connect(func(value: float): _ads_sens_input.text = str(round(value)) SettingsManager.ads_sensitivity = value / 10000.0 SettingsManager._save_settings() ) _ads_sens_input.text_submitted.connect(func(new_text: String): var val = new_text.to_float() val = clampf(val, 1.0, 100.0) _ads_sens_slider.value = val _ads_sens_input.text = str(round(val)) SettingsManager.ads_sensitivity = val / 10000.0 SettingsManager._save_settings() ) var spacer_mouse = Control.new() spacer_mouse.size_flags_vertical = Control.SIZE_EXPAND_FILL mouse_tab.add_child(spacer_mouse) var reset_mouse_btn = Button.new() reset_mouse_btn.text = "Reset to Defaults" reset_mouse_btn.pressed.connect(func(): _mouse_sens_slider.value = 20.0 _ads_sens_slider.value = 10.0 SettingsManager.mouse_sensitivity = 0.002 SettingsManager.ads_sensitivity = 0.001 SettingsManager._save_settings() ) mouse_tab.add_child(reset_mouse_btn) # Keybindings Tab var key_tab = VBoxContainer.new() key_tab.name = "Keybindings" tab_container.add_child(key_tab) var scroll = ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL key_tab.add_child(scroll) keybind_vbox = VBoxContainer.new() keybind_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL scroll.add_child(keybind_vbox) var reset_key_btn = Button.new() reset_key_btn.text = "Reset to Defaults" reset_key_btn.pressed.connect(func(): SettingsManager.reset_to_defaults() _populate_keybindings() ) key_tab.add_child(reset_key_btn) # Advanced Tab var advanced_tab = VBoxContainer.new() advanced_tab.name = "Advanced" advanced_tab.add_theme_constant_override("separation", 10) tab_container.add_child(advanced_tab) var debug_label = Label.new() debug_label.text = "--- Debug ---" debug_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER advanced_tab.add_child(debug_label) _debug_ui_checkbox = CheckButton.new() _debug_ui_checkbox.text = "Show Debug UI (Speed, State, Buffs)" _debug_ui_checkbox.button_pressed = SettingsManager.show_debug_ui _debug_ui_checkbox.toggled.connect(func(toggled_on: bool): SettingsManager.show_debug_ui = toggled_on SettingsManager._save_settings() ) advanced_tab.add_child(_debug_ui_checkbox) _show_fps_checkbox = CheckButton.new() _show_fps_checkbox.text = "Show FPS Counter" _show_fps_checkbox.button_pressed = SettingsManager.show_fps _show_fps_checkbox.toggled.connect(func(toggled_on: bool): SettingsManager.show_fps = toggled_on SettingsManager._save_settings() ) advanced_tab.add_child(_show_fps_checkbox) _show_movement_speed_checkbox = CheckButton.new() _show_movement_speed_checkbox.text = "Show Movement Speed Indicator" _show_movement_speed_checkbox.button_pressed = SettingsManager.show_movement_speed _show_movement_speed_checkbox.toggled.connect(func(toggled_on: bool): SettingsManager.show_movement_speed = toggled_on SettingsManager._save_settings() ) advanced_tab.add_child(_show_movement_speed_checkbox) var spacer_adv = Control.new() spacer_adv.size_flags_vertical = Control.SIZE_EXPAND_FILL advanced_tab.add_child(spacer_adv) var reset_adv_btn = Button.new() reset_adv_btn.text = "Reset to Defaults" reset_adv_btn.pressed.connect(func(): _debug_ui_checkbox.button_pressed = false SettingsManager.show_debug_ui = false _show_fps_checkbox.button_pressed = false SettingsManager.show_fps = false _show_movement_speed_checkbox.button_pressed = false SettingsManager.show_movement_speed = false SettingsManager._save_settings() ) advanced_tab.add_child(reset_adv_btn) # Rebind Overlay rebind_overlay = ColorRect.new() rebind_overlay.color = Color(0, 0, 0, 0.85) rebind_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) rebind_overlay.visible = false add_child(rebind_overlay) var rebind_center = CenterContainer.new() rebind_center.set_anchors_preset(Control.PRESET_FULL_RECT) rebind_overlay.add_child(rebind_center) rebind_label = Label.new() rebind_label.text = "Press any key..." rebind_label.add_theme_font_size_override("font_size", 32) rebind_center.add_child(rebind_label) # ── Quit Dialog ── quit_dialog = ConfirmationDialog.new() quit_dialog.title = "Quit Game" quit_dialog.dialog_text = "Are you sure you want to close the game?" add_child(quit_dialog) # ── Return Dialog ── return_dialog = ConfirmationDialog.new() return_dialog.title = "Main Menu" return_dialog.dialog_text = "Are you sure you want to return to the main menu?" add_child(return_dialog) # ── Character picker ───────────────────────────────────────────────────────── # # The list on the left, the character themselves on the right, turning. A name # in a dropdown is not a character selection screen — you pick a character by # looking at them, which is the whole reason the models exist. # # The preview is a real SkinnedPlayerModel in its own world, not a rendered # thumbnail, so it shows exactly what will spawn: the same cel look, the same # per-class outlines, the same cloth and hair on springs. A thumbnail would go # stale the first time a character was re-imported. const PREVIEW_SIZE := Vector2i(390, 560) func _build_loadout_character_preview(parent: PanelContainer) -> void: var preview_margin := MarginContainer.new() preview_margin.add_theme_constant_override("margin_left", 18) preview_margin.add_theme_constant_override("margin_right", 18) preview_margin.add_theme_constant_override("margin_top", 18) preview_margin.add_theme_constant_override("margin_bottom", 18) parent.add_child(preview_margin) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 12) preview_margin.add_child(column) column.add_child(UITheme.heading("CHARACTER PREVIEW", 24)) character_desc = UITheme.caption("", 17) character_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART character_desc.custom_minimum_size = Vector2(0, 48) column.add_child(character_desc) var vp_container := SubViewportContainer.new() vp_container.stretch = true vp_container.size_flags_vertical = Control.SIZE_EXPAND_FILL vp_container.custom_minimum_size = Vector2(PREVIEW_SIZE) column.add_child(vp_container) _preview_viewport = SubViewport.new() _preview_viewport.own_world_3d = true _preview_viewport.size = PREVIEW_SIZE _preview_viewport.msaa_3d = Viewport.MSAA_4X _preview_viewport.transparent_bg = true _preview_viewport.process_mode = Node.PROCESS_MODE_ALWAYS vp_container.add_child(_preview_viewport) var world := Node3D.new() _preview_viewport.add_child(world) var env := WorldEnvironment.new() env.environment = LevelEnvironment.make_environment("sunset") world.add_child(env) var key := DirectionalLight3D.new() key.rotation_degrees = Vector3(-28, 38, 0) key.light_color = Color(1.0, 0.93, 0.84) key.light_energy = 1.6 world.add_child(key) var fill := DirectionalLight3D.new() fill.rotation_degrees = Vector3(-16, -128, 0) fill.light_color = Color(0.48, 0.62, 1.0) fill.light_energy = 0.55 world.add_child(fill) _preview_pivot = Node3D.new() _preview_pivot.rotation_degrees.y = 180.0 world.add_child(_preview_pivot) var camera := Camera3D.new() camera.position = Vector3(0, 1.05, 2.65) camera.rotation_degrees = Vector3(-4, 0, 0) camera.fov = 38.0 world.add_child(camera) ## Build the turntable model for one skin. A colour-tint skin has no GLB, so the ## preview is emptied rather than left showing whoever was selected before it. func _load_preview(id: String, skin) -> void: if id == _preview_id: return _preview_id = id if is_instance_valid(_preview_model): _preview_model.queue_free() _preview_model = null # Every character gets shown from the front. Without this the turntable # carries on from wherever the last one left it, so the third character you # look at is side-on and the fourth has their back to you. if is_instance_valid(_preview_pivot): _preview_pivot.rotation_degrees.y = 180.0 if skin == null or skin.model_path == "" \ or not ResourceLoader.exists(skin.model_path): return var model := SkinnedPlayerModel.new() model.model_path = skin.model_path model.skin_id = id model.mecha_theme = str(skin.get("mecha_theme")) # Not first-person and not shadows-only: this is the one place the local # player is supposed to see their own character in full. model.first_person_mode = false model.shadows_only = false model.process_mode = Node.PROCESS_MODE_ALWAYS _preview_pivot.add_child(model) _preview_model = model _refresh_preview_weapon() func _process(delta: float) -> void: # Turntable. Only while the picker is actually on screen — the rest of the # time this node is an invisible autoload and has no business spending # frames. if loadout_editor and loadout_editor.visible and is_instance_valid(_preview_pivot): _preview_pivot.rotate_y(delta * 0.45) ## The player this menu belongs to. Spawners differ between the built levels and ## the test harness, so both places are checked. func _local_player() -> Node: var pid := str(multiplayer.get_unique_id()) var scene := get_tree().current_scene if scene == null: return null var found := scene.get_node_or_null(pid) if found == null: var spawner := scene.get_node_or_null("PlayerSpawner") if spawner: found = spawner.get_node_or_null(pid) return found func _build_vol_control(parent: Node, label_name: String) -> Array: var hbox = HBoxContainer.new() parent.add_child(hbox) var lbl = Label.new() lbl.text = label_name lbl.custom_minimum_size = Vector2(150, 0) hbox.add_child(lbl) var slider = HSlider.new() slider.min_value = 0.0 slider.max_value = 100.0 slider.step = 1.0 slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL slider.custom_minimum_size = Vector2(150, 0) hbox.add_child(slider) var input = LineEdit.new() input.custom_minimum_size = Vector2(60, 0) hbox.add_child(input) return [slider, input] func _on_vol_changed(setting_name: String, value: float, input_node: LineEdit) -> void: SettingsManager.set(setting_name, value) input_node.text = str(value) SettingsManager._save_settings() SettingsManager.apply_audio_settings() func _on_vol_text_changed(setting_name: String, text: String, slider_node: HSlider, input_node: LineEdit) -> void: var val = clampf(text.to_float(), 0.0, 100.0) SettingsManager.set(setting_name, val) slider_node.value = val input_node.text = str(val) SettingsManager._save_settings() SettingsManager.apply_audio_settings() func _create_label(text: String) -> Label: var l = Label.new() l.text = text return l func _connect_signals() -> void: resume_btn.pressed.connect(_resume) respawn_btn.pressed.connect(_respawn) loadouts_btn.pressed.connect(_show_loadouts) settings_btn.pressed.connect(_show_settings) return_btn.pressed.connect(_show_return_dialog) exit_btn.pressed.connect(_show_exit_dialog) back_to_main_btn.pressed.connect(_show_main_menu) settings_back_btn.pressed.connect(_show_main_menu) save_loadout_btn.pressed.connect(_save_current_loadout) skin_opt.item_selected.connect(_on_skin_option_selected) for option in [primary1_opt, primary2_opt, special_opt, melee_opt]: option.item_selected.connect(func(_index: int): _refresh_loadout_summary()) quit_dialog.confirmed.connect(_quit_game) return_dialog.confirmed.connect(_return_to_main_menu) func _show_return_dialog() -> void: return_dialog.popup_centered() func _return_to_main_menu() -> void: _resume() # Reset pause state and cursor mode var nm = get_node_or_null("/root/NetworkManager") if nm: nm.disconnect_game() get_tree().change_scene_to_file("res://ui/main_menu/main_menu.tscn") func _show_main_menu() -> void: if get_tree().current_scene and get_tree().current_scene.name == "MainMenu": visible = false return main_vbox.visible = true loadout_editor.visible = false settings_editor.visible = false func _show_loadouts() -> void: main_vbox.visible = false settings_editor.visible = false loadout_editor.visible = true _populate_loadout_list() var index := clampi(LoadoutManager.active_loadout_index, 0, LoadoutManager.loadouts.size() - 1) _edit_loadout(index) func _populate_video_ui() -> void: if _display_mode_opt: for i in range(_display_mode_opt.item_count): if _display_mode_opt.get_item_id(i) == SettingsManager.display_mode: _display_mode_opt.select(i) break if _resolution_opt: _resolution_opt.select(SettingsManager.resolution_index) if _fps_slider and _fps_input: if SettingsManager.fps_cap == 0: _fps_slider.value = 361.0 _fps_input.text = "Uncapped" else: _fps_slider.value = float(SettingsManager.fps_cap) _fps_input.text = str(SettingsManager.fps_cap) func _populate_audio_ui() -> void: if _vol_master_slider: _vol_master_slider.value = SettingsManager.vol_master if _vol_master_input: _vol_master_input.text = str(SettingsManager.vol_master) if _vol_sfx_slider: _vol_sfx_slider.value = SettingsManager.vol_sfx if _vol_sfx_input: _vol_sfx_input.text = str(SettingsManager.vol_sfx) if _vol_music_slider: _vol_music_slider.value = SettingsManager.vol_music if _vol_music_input: _vol_music_input.text = str(SettingsManager.vol_music) if _vol_wind_slider: _vol_wind_slider.value = SettingsManager.vol_wind if _vol_wind_input: _vol_wind_input.text = str(SettingsManager.vol_wind) func _show_settings() -> void: main_vbox.visible = false loadout_editor.visible = false settings_editor.visible = true _populate_keybindings() _populate_video_ui() _populate_audio_ui() if _debug_ui_checkbox: _debug_ui_checkbox.button_pressed = SettingsManager.show_debug_ui if _show_fps_checkbox: _show_fps_checkbox.button_pressed = SettingsManager.show_fps if _show_movement_speed_checkbox: _show_movement_speed_checkbox.button_pressed = SettingsManager.show_movement_speed if _world_fov_slider: _world_fov_slider.value = SettingsManager.world_fov _world_fov_input.text = str(round(SettingsManager.world_fov)) if _mouse_sens_slider: var display_val = round(SettingsManager.mouse_sensitivity * 10000.0) _mouse_sens_slider.value = display_val _mouse_sens_input.text = str(display_val) var ads_display_val = round(SettingsManager.ads_sensitivity * 10000.0) _ads_sens_slider.value = ads_display_val _ads_sens_input.text = str(ads_display_val) _mouse_sens_slider.value = display_val if _mouse_sens_input: _mouse_sens_input.text = str(display_val) func _populate_keybindings() -> void: for c in keybind_vbox.get_children(): c.queue_free() var actions = SettingsManager.default_bindings.keys() for action in actions: var hbox = HBoxContainer.new() var lbl = Label.new() lbl.text = action.capitalize() lbl.custom_minimum_size = Vector2(200, 0) hbox.add_child(lbl) var btn = Button.new() btn.text = _get_key_name(action) btn.custom_minimum_size = Vector2(150, 0) btn.pressed.connect(func(): _start_rebind(action)) hbox.add_child(btn) keybind_vbox.add_child(hbox) func _start_rebind(action: String) -> void: waiting_for_input_action = action rebind_label.text = "Press any key for: " + action.capitalize() rebind_overlay.visible = true func _get_key_name(action: String) -> String: if not InputMap.has_action(action): return "?" var events = InputMap.action_get_events(action) for e in events: if e is InputEventKey: var code = e.physical_keycode if e.physical_keycode != 0 else e.keycode return OS.get_keycode_string(code) elif e is InputEventMouseButton: if e.button_index == MOUSE_BUTTON_LEFT: return "LClick" elif e.button_index == MOUSE_BUTTON_RIGHT: return "RClick" elif e.button_index == MOUSE_BUTTON_MIDDLE: return "MClick" elif e.button_index == MOUSE_BUTTON_WHEEL_UP: return "MWheel Up" elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN: return "MWheel Down" return "?" func _populate_loadout_list() -> void: for c in loadout_list_vbox.get_children(): c.queue_free() for i in range(LoadoutManager.loadouts.size()): var loadout: Dictionary = LoadoutManager.loadouts[i] var loadout_index := i var is_active := loadout_index == LoadoutManager.active_loadout_index var card := UITheme.card() card.custom_minimum_size = Vector2(350, 138) loadout_list_vbox.add_child(card) var margin := MarginContainer.new() for side in ["left", "right", "top", "bottom"]: margin.add_theme_constant_override("margin_" + side, 14) card.add_child(margin) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 7) margin.add_child(content) var heading := UITheme.heading( str(loadout.get("name", "Loadout")) + (" • ACTIVE" if is_active else ""), 21 ) content.add_child(heading) var details := Label.new() details.text = "%s\n%s · %s\n%s · %s" % [ _skin_name(str(loadout.get("skin", "default"))), _weapon_name(str(loadout.get("primary_1", "none"))), _weapon_name(str(loadout.get("primary_2", "none"))), _weapon_name(str(loadout.get("special", "none"))), _weapon_name(str(loadout.get("melee", "none"))), ] details.modulate = Color(0.82, 0.86, 0.94) content.add_child(details) var actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 8) content.add_child(actions) var edit_btn := Button.new() edit_btn.text = "Edit" edit_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL edit_btn.pressed.connect(func(): _edit_loadout(loadout_index)) actions.add_child(edit_btn) var equip_btn := Button.new() equip_btn.text = "Equip" equip_btn.disabled = is_active equip_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL equip_btn.pressed.connect(func(): _equip_loadout(loadout_index)) actions.add_child(equip_btn) func _equip_loadout(idx: int) -> void: if not LoadoutManager.set_active_loadout(idx): return _apply_active_loadout_skin() _populate_loadout_list() _edit_loadout(idx) func _edit_loadout(idx: int) -> void: if idx < 0 or idx >= LoadoutManager.loadouts.size(): return editing_index = idx var loadout: Dictionary = LoadoutManager.loadouts[idx] name_edit.text = str(loadout.get("name", "Loadout")) edit_title_label.text = "EDIT " + name_edit.text.to_upper() _populate_skin_options(str(loadout.get("skin", "default"))) _populate_options(primary1_opt, "primary", str(loadout.get("primary_1", "none"))) _populate_options(primary2_opt, "primary", str(loadout.get("primary_2", "none"))) _populate_options(special_opt, "special", str(loadout.get("special", "none"))) _populate_options(melee_opt, "melee", str(loadout.get("melee", "none"))) edit_panel.visible = true _on_skin_option_selected(skin_opt.selected) _refresh_loadout_summary() func _populate_skin_options(current_id: String) -> void: skin_opt.clear() var skin_ids: Array = SkinManager.get_skin_ids() skin_ids.sort() var selected_index := 0 for id in skin_ids: var skin = SkinManager.get_skin(id) if skin == null or not skin.is_unlocked: continue var item_index := skin_opt.item_count skin_opt.add_item(skin.skin_name) skin_opt.set_item_metadata(item_index, id) if id == current_id: selected_index = item_index if skin_opt.item_count > 0: skin_opt.select(selected_index) func _on_skin_option_selected(index: int) -> void: if index < 0 or index >= skin_opt.item_count: return var skin_id := str(skin_opt.get_item_metadata(index)) var skin = SkinManager.get_skin(skin_id) character_desc.text = skin.description if skin.description != "" else skin.skin_name _load_preview(skin_id, skin) _refresh_loadout_summary() func _selected_metadata(option: OptionButton) -> String: if option == null or option.selected < 0 or option.selected >= option.item_count: return "none" return str(option.get_item_metadata(option.selected)) func _weapon_name(id: String) -> String: var weapon: Dictionary = LoadoutManager.weapon_db.get(id, LoadoutManager.weapon_db["none"]) return str(weapon.get("name", id)) func _skin_name(id: String) -> String: var skin = SkinManager.get_skin(id) return skin.skin_name if skin != null else id func _refresh_loadout_summary() -> void: if loadout_summary == null: return loadout_summary.text = "CHARACTER %s\nPRIMARY %s · %s\nSPECIAL %s\nMELEE %s" % [ _skin_name(_selected_metadata(skin_opt)), _weapon_name(_selected_metadata(primary1_opt)), _weapon_name(_selected_metadata(primary2_opt)), _weapon_name(_selected_metadata(special_opt)), _weapon_name(_selected_metadata(melee_opt)), ] _refresh_preview_weapon() func _refresh_preview_weapon() -> void: if not is_instance_valid(_preview_model): return var weapon_id := _selected_metadata(primary1_opt) if weapon_id == "none": weapon_id = _selected_metadata(primary2_opt) var weapon: Dictionary = LoadoutManager.weapon_db.get(weapon_id, LoadoutManager.weapon_db["none"]) _preview_model.set_weapon(str(weapon.get("script", ""))) func _apply_active_loadout_skin() -> void: var skin_id := LoadoutManager.get_active_skin_id() SkinManager.set_active_skin(skin_id) var player := _local_player() if player and player.has_method("set_skin"): player.set_skin(skin_id) func _populate_options(opt: OptionButton, category: String, current_id: String) -> void: opt.clear() var idx = 0 var select_idx = 0 opt.add_item("None", 0) opt.set_item_metadata(0, "none") if current_id == "none": select_idx = 0 idx += 1 for key in LoadoutManager.weapon_db: if key == "none": continue var w = LoadoutManager.weapon_db[key] if w["category"] == category: opt.add_item(w["name"], idx) opt.set_item_metadata(idx, key) if key == current_id: select_idx = idx idx += 1 opt.select(select_idx) func _save_current_loadout() -> void: if editing_index >= 0 and editing_index < LoadoutManager.loadouts.size(): var l: Dictionary = LoadoutManager.loadouts[editing_index] if name_edit.text.strip_edges() != "": l["name"] = name_edit.text.strip_edges() name_edit.text = l["name"] l["skin"] = _selected_metadata(skin_opt) l["primary_1"] = _selected_metadata(primary1_opt) l["primary_2"] = _selected_metadata(primary2_opt) l["special"] = _selected_metadata(special_opt) l["melee"] = _selected_metadata(melee_opt) LoadoutManager.save_loadouts() if editing_index == LoadoutManager.active_loadout_index: _apply_active_loadout_skin() _populate_loadout_list() edit_title_label.text = "EDIT " + str(l["name"]).to_upper() _refresh_loadout_summary() func _show_exit_dialog() -> void: quit_dialog.popup_centered() func _quit_game() -> void: get_tree().quit()