extends CanvasLayer var bg: ColorRect var main_vbox: VBoxContainer var resume_btn: Button var respawn_btn: Button var character_btn: Button var loadouts_btn: Button var settings_btn: Button var return_btn: Button var exit_btn: Button # Character picker var character_editor: Control var character_list: ItemList var character_desc: Label var character_back_btn: Button 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 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) character_btn = Button.new() character_btn.text = "Character" character_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(character_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) _build_character_editor() # ── Loadout Editor Root ── loadout_editor = Control.new() loadout_editor.set_anchors_preset(Control.PRESET_FULL_RECT) bg.add_child(loadout_editor) var editor_center = CenterContainer.new() editor_center.set_anchors_preset(Control.PRESET_FULL_RECT) loadout_editor.add_child(editor_center) var editor_hbox = HBoxContainer.new() editor_hbox.add_theme_constant_override("separation", 40) editor_center.add_child(editor_hbox) var left_vbox = VBoxContainer.new() editor_hbox.add_child(left_vbox) var list_title = Label.new() list_title.text = "Your Loadouts" list_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER left_vbox.add_child(list_title) loadout_list_vbox = VBoxContainer.new() left_vbox.add_child(loadout_list_vbox) back_to_main_btn = Button.new() back_to_main_btn.text = "Back to Menu" back_to_main_btn.custom_minimum_size = Vector2(0, 40) left_vbox.add_child(back_to_main_btn) edit_panel = PanelContainer.new() editor_hbox.add_child(edit_panel) var edit_vbox = VBoxContainer.new() edit_vbox.add_theme_constant_override("separation", 10) edit_panel.add_child(edit_vbox) var edit_title = Label.new() edit_title.text = "Edit Loadout" edit_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER edit_vbox.add_child(edit_title) name_edit = LineEdit.new() name_edit.placeholder_text = "Loadout Name" name_edit.custom_minimum_size = Vector2(250, 0) edit_vbox.add_child(name_edit) edit_vbox.add_child(_create_label("Primary 1:")) primary1_opt = OptionButton.new() edit_vbox.add_child(primary1_opt) edit_vbox.add_child(_create_label("Primary 2:")) primary2_opt = OptionButton.new() edit_vbox.add_child(primary2_opt) edit_vbox.add_child(_create_label("Special:")) special_opt = OptionButton.new() edit_vbox.add_child(special_opt) var melee_lbl = Label.new() melee_lbl.text = "Melee" edit_vbox.add_child(melee_lbl) melee_opt = OptionButton.new() edit_vbox.add_child(melee_opt) save_loadout_btn = Button.new() save_loadout_btn.text = "Save Loadout" save_loadout_btn.custom_minimum_size = Vector2(0, 40) edit_vbox.add_child(save_loadout_btn) # ── 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(520, 640) func _build_character_editor() -> void: character_editor = Control.new() character_editor.set_anchors_preset(Control.PRESET_FULL_RECT) character_editor.visible = false bg.add_child(character_editor) var center = CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) character_editor.add_child(center) var row = HBoxContainer.new() row.add_theme_constant_override("separation", 34) center.add_child(row) # Left: the roster var left = VBoxContainer.new() left.add_theme_constant_override("separation", 14) left.custom_minimum_size = Vector2(340, 0) row.add_child(left) var heading := UITheme.title("CHARACTER", 52) left.add_child(heading) left.add_child(UITheme.divider(0.28)) character_list = ItemList.new() character_list.custom_minimum_size = Vector2(340, 380) character_list.auto_height = false left.add_child(character_list) character_desc = UITheme.heading("", 20) character_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART character_desc.custom_minimum_size = Vector2(340, 60) left.add_child(character_desc) character_back_btn = Button.new() character_back_btn.text = "Back" character_back_btn.custom_minimum_size = Vector2(0, 46) left.add_child(character_back_btn) # Right: the character, in a real 3D viewport var frame := UITheme.card() row.add_child(frame) var vp_container = SubViewportContainer.new() vp_container.stretch = true vp_container.custom_minimum_size = Vector2(PREVIEW_SIZE) frame.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 # The tree is paused while this menu is up. Without this the preview would # be a still frame: no turntable, and — worse — no spring solver, so the # character's hair and skirt would hang in their bind pose. _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) # Three-point-ish lighting, warm key and cool fill, which is what makes a # cel-shaded character read as rounded instead of as a sticker. 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() # Turned to face the camera. SkinnedPlayerModel yaws itself 180° on load # because glTF forward is +Z and players face -Z, which is right in a level # and means a preview camera sitting on +Z gets the back of the character's # head. The turntable starts from here. _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) func _show_character() -> void: main_vbox.visible = false loadout_editor.visible = false settings_editor.visible = false character_editor.visible = true _populate_character_list() func _populate_character_list() -> void: character_list.clear() var active: String = SkinManager.active_skin_id var selected := -1 for id in SkinManager.get_skin_ids(): var skin = SkinManager.get_skin(id) if not skin.is_unlocked: continue var idx := character_list.add_item(skin.skin_name) character_list.set_item_metadata(idx, id) if id == active: selected = idx if selected >= 0: character_list.select(selected) _on_character_selected(selected) elif character_list.item_count > 0: character_list.select(0) _on_character_selected(0) ## Selecting a character equips it immediately — there is no separate confirm. ## ## A picker that needs a second click to take effect makes you compare a preview ## against a memory of the last one. Applying on selection means the character ## behind the menu changes as you arrow through the list, which IS the ## comparison. Nothing here is destructive, so there is nothing to confirm. func _on_character_selected(index: int) -> void: if index < 0 or index >= character_list.item_count: return var id: String = character_list.get_item_metadata(index) var skin = SkinManager.get_skin(id) character_desc.text = skin.description if skin.description != "" else skin.skin_name _load_preview(id, skin) SkinManager.set_active_skin(id) var player := _local_player() if player and player.has_method("set_skin"): player.set_skin(id) ## 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 # 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 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 character_editor and character_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) character_btn.pressed.connect(_show_character) character_back_btn.pressed.connect(_show_main_menu) character_list.item_selected.connect(_on_character_selected) 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) 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 character_editor.visible = false func _show_loadouts() -> void: main_vbox.visible = false settings_editor.visible = false character_editor.visible = false loadout_editor.visible = true edit_panel.visible = false _populate_loadout_list() 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 character_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 l = LoadoutManager.loadouts[i] var hbox = HBoxContainer.new() var btn = Button.new() var is_active = (i == LoadoutManager.active_loadout_index) btn.text = l["name"] + (" (Active)" if is_active else "") btn.custom_minimum_size = Vector2(200, 40) btn.pressed.connect(func(): _edit_loadout(i)) hbox.add_child(btn) var equip_btn = Button.new() equip_btn.text = "Equip" equip_btn.disabled = is_active equip_btn.pressed.connect(func(): _equip_loadout(i)) hbox.add_child(equip_btn) loadout_list_vbox.add_child(hbox) func _equip_loadout(idx: int) -> void: LoadoutManager.active_loadout_index = idx LoadoutManager.save_loadouts() _populate_loadout_list() func _edit_loadout(idx: int) -> void: editing_index = idx var l = LoadoutManager.loadouts[idx] name_edit.text = l["name"] _populate_options(primary1_opt, "primary", l["primary_1"]) _populate_options(primary2_opt, "primary", l["primary_2"]) _populate_options(special_opt, "special", l["special"]) if l.has("melee"): _populate_options(melee_opt, "melee", l["melee"]) edit_panel.visible = true 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 = LoadoutManager.loadouts[editing_index] if name_edit.text.strip_edges() != "": l["name"] = name_edit.text.strip_edges() name_edit.text = l["name"] l["primary_1"] = primary1_opt.get_item_metadata(primary1_opt.get_selected_id()) l["primary_2"] = primary2_opt.get_item_metadata(primary2_opt.get_selected_id()) l["special"] = special_opt.get_item_metadata(special_opt.get_selected_id()) l["melee"] = melee_opt.get_item_metadata(melee_opt.get_selected_id()) LoadoutManager.save_loadouts() _populate_loadout_list() edit_panel.visible = false func _show_exit_dialog() -> void: quit_dialog.popup_centered() func _quit_game() -> void: get_tree().quit()