From 0e52fdd68f4a0b00fe59bc356c8b4be0ed557564 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 23:43:44 +0900 Subject: [PATCH] Add in-game settings panel --- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- scripts/scenes/battle_scene.gd | 251 ++++++++++++++++++++++++++++++++- tools/smoke_title_menu.gd | 19 +++ 4 files changed, 271 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cae4916..9b34840 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Campaign completion screen. - New campaign save reset. - Manual campaign checkpoint save/load. -- Cinematic title screen uses the battlefield backdrop and Cao Cao portrait art, offers start/load/settings, leads new campaigns through a short Cao Cao and Xiahou Dun opening prologue, and persists BGM/SFX volume, sound toggles, and edge-scroll preference in `user://heros_settings.json`. +- Cinematic title screen uses the battlefield backdrop and Cao Cao portrait art, offers start/load/settings, leads new campaigns through a short Cao Cao and Xiahou Dun opening prologue, exposes the same audio/edge-scroll settings from the top in-game toolbar, and persists BGM/SFX volume, sound toggles, and edge-scroll preference in `user://heros_settings.json`. - Victory and defeat result overlay. - Dialogue portrait slot with officer default image paths, optional per-line overrides, and speaker-initial fallback. - Initial AI-generated photorealistic officer portraits for Cao Cao, Xiahou Dun, Xiahou Yuan, Cao Ren, Dian Wei, Guo Jia, and Zhang He. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1508a46..7cc8a05 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -56,7 +56,7 @@ `BattleState` owns tactical runtime fields. `CampaignState` saves only campaign-facing data: completed scenario ids, gold, inventory counts, joined officers, campaign flags, and a player roster snapshot. It does not save map positions, pre-battle Roster/Formation choices, action flags, temporary status effects, or transient battle occupancy. -Presentation preferences are separate from campaign progression. `BattleScene` reads and writes `user://heros_settings.json` for title-menu BGM/SFX toggles, BGM/SFX volume percentages, and edge-scroll preference, then applies those values to menu and battle audio playback without mutating `CampaignState`. +Presentation preferences are separate from campaign progression. `BattleScene` reads and writes `user://heros_settings.json` for BGM/SFX toggles, BGM/SFX volume percentages, and edge-scroll preference, exposing the same values through the title settings menu and the top in-game toolbar settings panel. These values apply to menu and battle audio playback without mutating `CampaignState`. Joined officers gate whether `requires_joined` player deployments are loaded at all. Rewards and post-battle choices can add or remove joined officers without deleting their saved roster snapshot, so a later rejoin can preserve progression. When a save loads, `CampaignState` scans completed scenarios in campaign order and reapplies only officer join/leave transitions from rewards and the saved `applied_post_battle_choices` ledger, allowing added recruitment rewards to reach older saves without replaying gold or items. Old saves without the ledger can backfill a choice id only when exactly one completed scenario choice matches saved flags. Roster entries are keyed by `officer_id` when available, so future scenarios can deploy the same joined officer under different scenario-specific `unit_id` values. Saved progression overlays final battle stats for now; a later equipment refactor should split base stats from equipment bonuses before adding item leveling or stat recalculation. New battles currently start player units healed to their saved maximum HP/MP. Skill access is carried in roster snapshots, with class defaults still applied for old saves that do not include skills. Promoted class ids are saved in the roster, and the next battle rehydrates class-derived movement, growth, skills, and range from the saved class id before equipment range is recalculated. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index cbd4543..3c6d1c5 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -134,6 +134,7 @@ const HUD_COMMAND_GRID_COLUMNS := 4 const TOP_TOOL_BUTTON_SIZE := Vector2(42, 42) const BATTLE_UNIT_LIST_PANEL_SIZE := Vector2(420, 360) const BATTLE_UNIT_LIST_ROW_SIZE := Vector2(372, 42) +const SETTINGS_PANEL_SIZE := Vector2(420, 286) const LOCAL_COMMAND_BUTTON_SIZE := Vector2(92, 38) const LOCAL_COMMAND_CANCEL_BUTTON_SIZE := Vector2(196, 30) const POST_MOVE_MENU_SIZE := Vector2(240, 146) @@ -478,6 +479,15 @@ var battle_unit_list_rows: VBoxContainer var battle_unit_list_mode := "ally" var top_save_button: Button var top_load_button: Button +var top_settings_button: Button +var settings_panel: PanelContainer +var settings_bgm_toggle: CheckButton +var settings_sfx_toggle: CheckButton +var settings_edge_scroll_toggle: CheckButton +var settings_bgm_volume_slider: HSlider +var settings_sfx_volume_slider: HSlider +var settings_bgm_volume_value_label: Label +var settings_sfx_volume_value_label: Label var restart_button: Button var new_campaign_button: Button var opening_prologue_root: Control @@ -1293,6 +1303,21 @@ func _make_toolbar_icon(kind: String) -> Texture2D: _icon_line(image, Vector2i(5, 25), Vector2i(27, 25), red, 3) _icon_line(image, Vector2i(8, 18), Vector2i(14, 23), red, 2) _icon_line(image, Vector2i(24, 18), Vector2i(18, 23), red, 2) + "settings": + _icon_circle_outline(image, Vector2i(16, 16), 9, shadow, 5) + _icon_circle_outline(image, Vector2i(16, 16), 9, ink, 3) + _icon_circle(image, Vector2i(16, 16), 3, red) + for point in [ + Vector2i(16, 4), + Vector2i(16, 28), + Vector2i(4, 16), + Vector2i(28, 16), + Vector2i(8, 8), + Vector2i(24, 8), + Vector2i(8, 24), + Vector2i(24, 24) + ]: + _icon_circle(image, point, 2, ink) "terrain": _icon_line(image, Vector2i(5, 22), Vector2i(13, 12), shadow, 5) _icon_line(image, Vector2i(13, 12), Vector2i(18, 18), shadow, 5) @@ -1946,7 +1971,7 @@ func _create_hud() -> void: var top_tool_row := HBoxContainer.new() top_tool_row.name = "TopToolBar" - top_tool_row.custom_minimum_size = Vector2(332, 48) + top_tool_row.custom_minimum_size = Vector2(378, 48) top_tool_row.add_theme_constant_override("separation", 5) top_row.add_child(top_tool_row) @@ -1966,6 +1991,11 @@ func _create_hud() -> void: battle_unit_list_button.pressed.connect(_on_battle_unit_list_pressed) top_tool_row.add_child(battle_unit_list_button) + top_settings_button = Button.new() + _configure_top_tool_button(top_settings_button, "TopSettingsButton", "settings", "환경 설정\n배경음, 효과음, 가장자리 스크롤을 조절합니다.") + top_settings_button.pressed.connect(_on_settings_pressed) + top_tool_row.add_child(top_settings_button) + top_save_button = Button.new() _configure_top_tool_button(top_save_button, "TopSaveButton", "save", "저장\n전투 전 군막에서 수동 전기를 봉인합니다.") top_save_button.pressed.connect(_on_top_manual_save_pressed) @@ -2058,6 +2088,120 @@ func _create_hud() -> void: battle_unit_list_rows.add_theme_constant_override("separation", 5) battle_unit_scroll.add_child(battle_unit_list_rows) + settings_panel = PanelContainer.new() + settings_panel.name = "SettingsPanel" + settings_panel.visible = false + settings_panel.mouse_filter = Control.MOUSE_FILTER_STOP + settings_panel.position = Vector2(824, 88) + settings_panel.size = SETTINGS_PANEL_SIZE + settings_panel.custom_minimum_size = SETTINGS_PANEL_SIZE + _apply_panel_style(settings_panel, "compact") + root.add_child(settings_panel) + + var settings_column := VBoxContainer.new() + settings_column.add_theme_constant_override("separation", 8) + settings_panel.add_child(settings_column) + + var settings_header_row := HBoxContainer.new() + settings_header_row.add_theme_constant_override("separation", 8) + settings_column.add_child(settings_header_row) + + var settings_title_label := Label.new() + settings_title_label.text = "환경 설정" + settings_title_label.custom_minimum_size = Vector2(250, 28) + settings_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _apply_label_style(settings_title_label, UI_OLD_BRONZE, 16) + settings_header_row.add_child(settings_title_label) + + var settings_close_button := Button.new() + settings_close_button.text = "닫기" + settings_close_button.tooltip_text = "환경 설정을 닫습니다." + settings_close_button.custom_minimum_size = Vector2(76, 28) + _apply_button_style(settings_close_button) + settings_close_button.pressed.connect(_hide_settings_panel) + settings_header_row.add_child(settings_close_button) + + settings_column.add_child(_make_seal_ribbon(374, 12)) + + settings_bgm_toggle = CheckButton.new() + settings_bgm_toggle.text = "배경음" + settings_bgm_toggle.custom_minimum_size = Vector2(374, 30) + settings_bgm_toggle.tooltip_text = "배경 음악을 켜거나 끕니다." + settings_bgm_toggle.toggled.connect(_on_settings_bgm_toggled) + settings_column.add_child(settings_bgm_toggle) + + var settings_bgm_volume_row := HBoxContainer.new() + settings_bgm_volume_row.custom_minimum_size = Vector2(374, 34) + settings_bgm_volume_row.add_theme_constant_override("separation", 8) + settings_column.add_child(settings_bgm_volume_row) + + var settings_bgm_volume_caption := Label.new() + settings_bgm_volume_caption.text = "배경음 크기" + settings_bgm_volume_caption.custom_minimum_size = Vector2(94, 30) + settings_bgm_volume_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(settings_bgm_volume_caption, UI_PARCHMENT_TEXT, 13) + settings_bgm_volume_row.add_child(settings_bgm_volume_caption) + + settings_bgm_volume_slider = HSlider.new() + settings_bgm_volume_slider.min_value = 0.0 + settings_bgm_volume_slider.max_value = 100.0 + settings_bgm_volume_slider.step = 5.0 + settings_bgm_volume_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + settings_bgm_volume_slider.custom_minimum_size = Vector2(194, 30) + settings_bgm_volume_slider.tooltip_text = "배경 음악의 크기를 조절합니다." + settings_bgm_volume_slider.value_changed.connect(_on_settings_bgm_volume_changed) + settings_bgm_volume_row.add_child(settings_bgm_volume_slider) + + settings_bgm_volume_value_label = Label.new() + settings_bgm_volume_value_label.custom_minimum_size = Vector2(48, 30) + settings_bgm_volume_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + settings_bgm_volume_value_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(settings_bgm_volume_value_label, UI_PARCHMENT_TEXT, 13) + settings_bgm_volume_row.add_child(settings_bgm_volume_value_label) + + settings_sfx_toggle = CheckButton.new() + settings_sfx_toggle.text = "효과음" + settings_sfx_toggle.custom_minimum_size = Vector2(374, 30) + settings_sfx_toggle.tooltip_text = "버튼과 전투 효과음을 켜거나 끕니다." + settings_sfx_toggle.toggled.connect(_on_settings_sfx_toggled) + settings_column.add_child(settings_sfx_toggle) + + var settings_sfx_volume_row := HBoxContainer.new() + settings_sfx_volume_row.custom_minimum_size = Vector2(374, 34) + settings_sfx_volume_row.add_theme_constant_override("separation", 8) + settings_column.add_child(settings_sfx_volume_row) + + var settings_sfx_volume_caption := Label.new() + settings_sfx_volume_caption.text = "효과음 크기" + settings_sfx_volume_caption.custom_minimum_size = Vector2(94, 30) + settings_sfx_volume_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(settings_sfx_volume_caption, UI_PARCHMENT_TEXT, 13) + settings_sfx_volume_row.add_child(settings_sfx_volume_caption) + + settings_sfx_volume_slider = HSlider.new() + settings_sfx_volume_slider.min_value = 0.0 + settings_sfx_volume_slider.max_value = 100.0 + settings_sfx_volume_slider.step = 5.0 + settings_sfx_volume_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + settings_sfx_volume_slider.custom_minimum_size = Vector2(194, 30) + settings_sfx_volume_slider.tooltip_text = "버튼과 전투 효과음의 크기를 조절합니다." + settings_sfx_volume_slider.value_changed.connect(_on_settings_sfx_volume_changed) + settings_sfx_volume_row.add_child(settings_sfx_volume_slider) + + settings_sfx_volume_value_label = Label.new() + settings_sfx_volume_value_label.custom_minimum_size = Vector2(48, 30) + settings_sfx_volume_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + settings_sfx_volume_value_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(settings_sfx_volume_value_label, UI_PARCHMENT_TEXT, 13) + settings_sfx_volume_row.add_child(settings_sfx_volume_value_label) + + settings_edge_scroll_toggle = CheckButton.new() + settings_edge_scroll_toggle.text = "가장자리 스크롤" + settings_edge_scroll_toggle.custom_minimum_size = Vector2(374, 30) + settings_edge_scroll_toggle.tooltip_text = "마우스를 전장 가장자리에 두면 화면을 움직입니다." + settings_edge_scroll_toggle.toggled.connect(_on_settings_edge_scroll_toggled) + settings_column.add_child(settings_edge_scroll_toggle) + objective_notice_panel = PanelContainer.new() objective_notice_panel.visible = false objective_notice_panel.position = Vector2(338, 88) @@ -3424,6 +3568,7 @@ func _create_hud() -> void: next_battle_button, opening_prologue_skip_button, opening_prologue_next_button, + top_settings_button, title_start_button, title_load_button, title_settings_button, @@ -3433,6 +3578,9 @@ func _create_hud() -> void: _apply_button_style(title_bgm_toggle) _apply_button_style(title_sfx_toggle) _apply_button_style(title_edge_scroll_toggle) + _apply_button_style(settings_bgm_toggle) + _apply_button_style(settings_sfx_toggle) + _apply_button_style(settings_edge_scroll_toggle) _apply_button_style(briefing_start_button, true) @@ -3502,16 +3650,32 @@ func _volume_percent_to_db(base_volume_db: float, percent: int) -> float: func _refresh_title_volume_controls() -> void: + if settings_bgm_toggle != null: + settings_bgm_toggle.set_pressed_no_signal(title_bgm_enabled) + if settings_sfx_toggle != null: + settings_sfx_toggle.set_pressed_no_signal(title_sfx_enabled) + if settings_edge_scroll_toggle != null: + settings_edge_scroll_toggle.set_pressed_no_signal(title_edge_scroll_enabled) if title_bgm_volume_slider != null: title_bgm_volume_slider.set_value_no_signal(float(title_bgm_volume_percent)) title_bgm_volume_slider.editable = title_bgm_enabled + if settings_bgm_volume_slider != null: + settings_bgm_volume_slider.set_value_no_signal(float(title_bgm_volume_percent)) + settings_bgm_volume_slider.editable = title_bgm_enabled if title_sfx_volume_slider != null: title_sfx_volume_slider.set_value_no_signal(float(title_sfx_volume_percent)) title_sfx_volume_slider.editable = title_sfx_enabled + if settings_sfx_volume_slider != null: + settings_sfx_volume_slider.set_value_no_signal(float(title_sfx_volume_percent)) + settings_sfx_volume_slider.editable = title_sfx_enabled if title_bgm_volume_value_label != null: title_bgm_volume_value_label.text = "%d%%" % title_bgm_volume_percent + if settings_bgm_volume_value_label != null: + settings_bgm_volume_value_label.text = "%d%%" % title_bgm_volume_percent if title_sfx_volume_value_label != null: title_sfx_volume_value_label.text = "%d%%" % title_sfx_volume_percent + if settings_sfx_volume_value_label != null: + settings_sfx_volume_value_label.text = "%d%%" % title_sfx_volume_percent func _on_bgm_finished() -> void: @@ -3851,6 +4015,12 @@ func _handle_cancel_input() -> bool: _update_hud() queue_redraw() return true + if settings_panel != null and settings_panel.visible: + _play_ui_cancel() + _hide_settings_panel() + _update_hud() + queue_redraw() + return true if not selected_skill_id.is_empty() or not selected_item_id.is_empty() or basic_attack_targeting: _play_ui_cancel() selected_skill_id = "" @@ -3890,6 +4060,10 @@ func _handle_key(event: InputEventKey) -> void: _hide_formation_menu() elif save_menu != null and save_menu.visible: _hide_save_menu() + elif settings_panel != null and settings_panel.visible: + _hide_settings_panel() + _update_hud() + queue_redraw() elif briefing_panel != null and briefing_panel.visible: if _set_briefing_objective_collapsed(true): _play_ui_cancel() @@ -7203,11 +7377,13 @@ func _update_hud() -> void: restart_button.disabled = campaign_complete_screen _update_threat_button() _update_battle_unit_list_button() + _update_settings_button() _update_top_archive_buttons() _update_post_move_menu() _update_post_move_picker() _update_targeting_hint_panel() _update_battle_unit_list_panel() + _update_settings_panel() _update_auto_end_turn_prompt() @@ -9987,6 +10163,76 @@ func _on_top_manual_load_pressed() -> void: _on_manual_load_pressed() +func _on_settings_pressed() -> void: + if top_settings_button == null or top_settings_button.disabled: + return + _play_ui_click() + if _is_settings_panel_visible(): + _hide_settings_panel() + else: + _show_settings_panel() + _update_hud() + + +func _show_settings_panel() -> void: + if settings_panel == null: + return + _hide_battle_unit_list_panel() + _refresh_title_volume_controls() + settings_panel.visible = true + settings_panel.move_to_front() + + +func _hide_settings_panel() -> void: + if settings_panel == null: + return + settings_panel.visible = false + + +func _is_settings_panel_visible() -> bool: + return settings_panel != null and settings_panel.visible + + +func _update_settings_button() -> void: + if top_settings_button == null: + return + var can_show := not _is_title_screen_visible() and not _is_opening_prologue_visible() + _set_action_button_state( + top_settings_button, + "환경 설정", + not can_show, + "배경음, 효과음, 가장자리 스크롤을 조절합니다." if can_show else "타이틀 화면에서는 왼쪽 메뉴의 환경 설정을 사용합니다." + ) + if not can_show: + _hide_settings_panel() + + +func _update_settings_panel() -> void: + if not _is_settings_panel_visible(): + return + _refresh_title_volume_controls() + + +func _on_settings_bgm_toggled(enabled: bool) -> void: + _on_title_bgm_toggled(enabled) + + +func _on_settings_sfx_toggled(enabled: bool) -> void: + _on_title_sfx_toggled(enabled) + + +func _on_settings_edge_scroll_toggled(enabled: bool) -> void: + _on_title_edge_scroll_toggled(enabled) + + +func _on_settings_bgm_volume_changed(value: float) -> void: + _on_title_bgm_volume_changed(value) + + +func _on_settings_sfx_volume_changed(value: float) -> void: + _on_title_sfx_volume_changed(value) + + func _on_battle_unit_list_pressed() -> void: if battle_unit_list_button == null or battle_unit_list_button.disabled: return @@ -9994,6 +10240,7 @@ func _on_battle_unit_list_pressed() -> void: if battle_unit_list_panel != null and battle_unit_list_panel.visible: _hide_battle_unit_list_panel() else: + _hide_settings_panel() _rebuild_battle_unit_list_rows() if battle_unit_list_panel != null: battle_unit_list_panel.visible = true @@ -10195,6 +10442,7 @@ func _show_title_menu() -> void: _hide_formation_menu() _hide_save_menu() _hide_battle_unit_list_panel() + _hide_settings_panel() _clear_pending_move_state() if briefing_panel != null: briefing_panel.visible = false @@ -11871,6 +12119,7 @@ func _show_campaign_complete() -> void: _hide_roster_menu() _hide_formation_menu() _hide_save_menu() + _hide_settings_panel() _clear_dialogue() active_scenario_id = campaign_state.current_scenario_id if briefing_panel != null: diff --git a/tools/smoke_title_menu.gd b/tools/smoke_title_menu.gd index b5ef59f..3d19179 100644 --- a/tools/smoke_title_menu.gd +++ b/tools/smoke_title_menu.gd @@ -130,6 +130,25 @@ func _check_title_menu_start_load_and_settings(failures: Array[String]) -> void: failures.append("loading a saved game should skip the new-campaign opening prologue") if scene.briefing_panel == null or not scene.briefing_panel.visible: failures.append("loading a saved game should enter the current campaign briefing") + if scene.top_settings_button == null: + failures.append("top toolbar should expose an in-game Settings icon") + scene._on_settings_pressed() + if scene.settings_panel == null or not scene.settings_panel.visible: + failures.append("top Settings icon should open the in-game settings panel") + if scene.settings_bgm_toggle == null or scene.settings_sfx_toggle == null or scene.settings_edge_scroll_toggle == null: + failures.append("in-game settings panel should expose BGM, SFX, and edge-scroll toggles") + if scene.settings_bgm_volume_slider == null or scene.settings_sfx_volume_slider == null: + failures.append("in-game settings panel should expose BGM and SFX volume sliders") + scene._on_settings_bgm_toggled(true) + scene._on_settings_sfx_toggled(true) + scene._on_settings_edge_scroll_toggled(true) + scene._on_settings_bgm_volume_changed(70.0) + scene._on_settings_sfx_volume_changed(60.0) + if not scene.title_bgm_enabled or not scene.title_sfx_enabled or not scene.title_edge_scroll_enabled: + failures.append("in-game settings toggles should update the shared settings state") + if scene.title_bgm_volume_percent != 70 or scene.title_sfx_volume_percent != 60: + failures.append("in-game settings sliders should update the shared volume state") + scene._hide_settings_panel() scene._on_new_campaign_pressed() if not scene._is_opening_prologue_visible(): failures.append("top New Campaign should also show the opening prologue")