From ab3f0a1124badd6997ebcadeaefea58937552987 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 23:05:41 +0900 Subject: [PATCH] Add title menu flow --- scripts/core/campaign_state.gd | 4 + scripts/scenes/battle_scene.gd | 351 ++++++++++++++++++++++++++++++++- tools/smoke_title_menu.gd | 112 +++++++++++ 3 files changed, 459 insertions(+), 8 deletions(-) create mode 100644 tools/smoke_title_menu.gd diff --git a/scripts/core/campaign_state.gd b/scripts/core/campaign_state.gd index 8a5d615..1acfe02 100644 --- a/scripts/core/campaign_state.gd +++ b/scripts/core/campaign_state.gd @@ -163,6 +163,10 @@ func save_game() -> bool: return _save_game_to_path(SAVE_PATH) +func has_save() -> bool: + return FileAccess.file_exists(SAVE_PATH) + + func has_manual_save() -> bool: return FileAccess.file_exists(MANUAL_SAVE_PATH) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index c3d3f5a..13bf0c5 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -461,6 +461,21 @@ var top_save_button: Button var top_load_button: Button var restart_button: Button var new_campaign_button: Button +var title_screen_root: Control +var title_panel: PanelContainer +var title_start_button: Button +var title_load_button: Button +var title_settings_button: Button +var title_status_label: Label +var title_settings_panel: PanelContainer +var title_bgm_toggle: CheckButton +var title_sfx_toggle: CheckButton +var title_edge_scroll_toggle: CheckButton +var title_settings_back_button: Button +var title_screen_visible := false +var title_bgm_enabled := true +var title_sfx_enabled := true +var title_edge_scroll_enabled := true var log_box: RichTextLabel var dialogue_queue: Array = [] var active_dialogue_lines: Array = [] @@ -500,17 +515,24 @@ func _ready() -> void: state.objective_updated.connect(_on_objective_updated) campaign_state.load_campaign(CAMPAIGN_PATH) campaign_state.load_or_start(campaign_state.get_start_scenario_id()) - if campaign_state.has_pending_post_battle_choice(): - _load_pending_post_battle_choice() - elif campaign_state.is_campaign_complete(): - _show_campaign_complete() - else: - _load_current_battle() - _show_briefing() + if not campaign_state.is_campaign_complete(): + if campaign_state.has_pending_post_battle_choice(): + _load_scenario(campaign_state.get_pending_post_battle_choice_scenario_id()) + else: + _load_current_battle() + _show_title_menu() _update_hud() func _unhandled_input(event: InputEvent) -> void: + if _is_title_screen_visible(): + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_ENTER: + _on_title_start_pressed() + elif event.keycode == KEY_ESCAPE and title_settings_panel != null and title_settings_panel.visible: + _on_title_settings_back_pressed() + return + if _is_dialogue_visible(): if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: _advance_dialogue() @@ -2958,6 +2980,134 @@ func _create_hud() -> void: next_battle_button.pressed.connect(_on_next_battle_pressed) result_column.add_child(next_battle_button) + title_screen_root = Control.new() + title_screen_root.name = "TitleScreen" + title_screen_root.visible = false + title_screen_root.mouse_filter = Control.MOUSE_FILTER_STOP + title_screen_root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.add_child(title_screen_root) + + var title_dim := ColorRect.new() + title_dim.name = "TitleInkWash" + title_dim.color = Color(0.025, 0.016, 0.010, 0.92) + title_dim.set_anchors_preset(Control.PRESET_FULL_RECT) + title_screen_root.add_child(title_dim) + + title_panel = PanelContainer.new() + title_panel.name = "TitleMenuPanel" + title_panel.position = Vector2(286, 76) + title_panel.size = Vector2(708, 568) + title_panel.custom_minimum_size = Vector2(708, 568) + _apply_panel_style(title_panel, "paper") + title_screen_root.add_child(title_panel) + + var title_column := VBoxContainer.new() + title_column.alignment = BoxContainer.ALIGNMENT_CENTER + title_column.add_theme_constant_override("separation", 10) + title_panel.add_child(title_column) + title_column.add_child(_make_scroll_rod(616, 5)) + title_column.add_child(_make_seal_ribbon(616, 18)) + + var title_caption := Label.new() + title_caption.text = "영웅전" + title_caption.custom_minimum_size = Vector2(616, 58) + title_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(title_caption, UI_PARCHMENT_TEXT, 34) + title_column.add_child(title_caption) + + var title_subtitle := Label.new() + title_subtitle.text = "난세의 첫 군령" + title_subtitle.custom_minimum_size = Vector2(616, 34) + title_subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_subtitle.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(title_subtitle, UI_OLD_BRONZE, 17) + title_column.add_child(title_subtitle) + + var title_body_panel := PanelContainer.new() + title_body_panel.custom_minimum_size = Vector2(616, 314) + _apply_panel_style(title_body_panel, "edict") + title_column.add_child(title_body_panel) + + var title_body_row := HBoxContainer.new() + title_body_row.add_theme_constant_override("separation", 10) + title_body_panel.add_child(title_body_row) + title_body_row.add_child(_make_edict_marker_stack(["", "", ""], 276)) + + var title_menu_column := VBoxContainer.new() + title_menu_column.add_theme_constant_override("separation", 10) + title_body_row.add_child(title_menu_column) + + title_status_label = Label.new() + title_status_label.custom_minimum_size = Vector2(432, 60) + title_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + title_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _apply_label_style(title_status_label, UI_AGED_INK, 14) + title_menu_column.add_child(title_status_label) + + title_start_button = Button.new() + title_start_button.text = "처음부터 시작" + title_start_button.custom_minimum_size = Vector2(432, 48) + title_start_button.tooltip_text = "새 전기를 열고 첫 전장부터 시작합니다." + title_start_button.pressed.connect(_on_title_start_pressed) + title_menu_column.add_child(title_start_button) + + title_load_button = Button.new() + title_load_button.text = "게임 로드" + title_load_button.custom_minimum_size = Vector2(432, 48) + title_load_button.tooltip_text = "저장된 전기를 불러옵니다." + title_load_button.pressed.connect(_on_title_load_pressed) + title_menu_column.add_child(title_load_button) + + title_settings_button = Button.new() + title_settings_button.text = "환경 설정" + title_settings_button.custom_minimum_size = Vector2(432, 48) + title_settings_button.tooltip_text = "소리와 전장 조작 옵션을 바꿉니다." + title_settings_button.pressed.connect(_on_title_settings_pressed) + title_menu_column.add_child(title_settings_button) + + title_settings_panel = PanelContainer.new() + title_settings_panel.visible = false + title_settings_panel.custom_minimum_size = Vector2(432, 134) + _apply_panel_style(title_settings_panel, "hud_info") + title_menu_column.add_child(title_settings_panel) + + var title_settings_column := VBoxContainer.new() + title_settings_column.add_theme_constant_override("separation", 6) + title_settings_panel.add_child(title_settings_column) + + title_bgm_toggle = CheckButton.new() + title_bgm_toggle.text = "배경음" + title_bgm_toggle.button_pressed = title_bgm_enabled + title_bgm_toggle.tooltip_text = "배경 음악을 켜거나 끕니다." + title_bgm_toggle.toggled.connect(_on_title_bgm_toggled) + title_settings_column.add_child(title_bgm_toggle) + + title_sfx_toggle = CheckButton.new() + title_sfx_toggle.text = "효과음" + title_sfx_toggle.button_pressed = title_sfx_enabled + title_sfx_toggle.tooltip_text = "버튼과 전투 효과음을 켜거나 끕니다." + title_sfx_toggle.toggled.connect(_on_title_sfx_toggled) + title_settings_column.add_child(title_sfx_toggle) + + title_edge_scroll_toggle = CheckButton.new() + title_edge_scroll_toggle.text = "가장자리 스크롤" + title_edge_scroll_toggle.button_pressed = title_edge_scroll_enabled + title_edge_scroll_toggle.tooltip_text = "마우스를 전장 가장자리에 두면 화면을 움직입니다." + title_edge_scroll_toggle.toggled.connect(_on_title_edge_scroll_toggled) + title_settings_column.add_child(title_edge_scroll_toggle) + + title_settings_back_button = Button.new() + title_settings_back_button.text = "설정 닫기" + title_settings_back_button.custom_minimum_size = Vector2(432, 34) + title_settings_back_button.tooltip_text = "환경 설정을 접습니다." + title_settings_back_button.pressed.connect(_on_title_settings_back_pressed) + title_menu_column.add_child(title_settings_back_button) + + title_body_row.add_child(_make_edict_marker_stack(["", "", ""], 276)) + title_column.add_child(_make_scroll_rod(616, 4)) + for hud_button in [ restart_button, new_campaign_button, @@ -2995,9 +3145,16 @@ func _create_hud() -> void: dialogue_previous_button, dialogue_continue_button, result_restart_button, - next_battle_button + next_battle_button, + title_start_button, + title_load_button, + title_settings_button, + title_settings_back_button ]: _apply_button_style(hud_button) + _apply_button_style(title_bgm_toggle) + _apply_button_style(title_sfx_toggle) + _apply_button_style(title_edge_scroll_toggle) _apply_button_style(briefing_start_button, true) @@ -3010,6 +3167,8 @@ func _create_audio() -> void: func _on_bgm_finished() -> void: + if not title_bgm_enabled: + return if current_bgm_key.is_empty() or bgm_player == null or bgm_player.stream == null: return bgm_player.play() @@ -3018,6 +3177,10 @@ func _on_bgm_finished() -> void: func _play_bgm(key: String) -> void: if bgm_player == null: return + if not title_bgm_enabled: + current_bgm_key = key + bgm_player.stop() + return if current_bgm_key == key and bgm_player.playing: return var stream := _bgm_stream_for(key) @@ -3038,6 +3201,8 @@ func _bgm_stream_for(key: String) -> AudioStream: func _play_sfx(key: String, volume_db := SFX_VOLUME_DB) -> void: + if not title_sfx_enabled: + return var stream := _sfx_stream_for(key) if stream == null: return @@ -3626,6 +3791,8 @@ func _draw_map_view_chrome() -> void: func _draw_edge_scroll_cues(view_rect: Rect2) -> void: + if not title_edge_scroll_enabled: + return if not _is_minimap_visible(): return var min_offset := _clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) @@ -6551,6 +6718,8 @@ func _edge_scroll_next_offset(current_offset: Vector2, mouse_position: Vector2, func _update_edge_scroll(delta: float) -> bool: + if not title_edge_scroll_enabled: + return false if _is_input_locked(): return false var viewport := get_viewport() @@ -9661,6 +9830,171 @@ func _on_battle_unit_list_row_pressed(unit_id: String) -> void: queue_redraw() +func _show_title_menu() -> void: + title_screen_visible = true + _play_bgm("menu") + _clear_dialogue() + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _hide_chapter_menu() + _hide_shop_menu() + _hide_talk_menu() + _hide_armory_menu() + _hide_roster_menu() + _hide_formation_menu() + _hide_save_menu() + _hide_battle_unit_list_panel() + _clear_pending_move_state() + if briefing_panel != null: + briefing_panel.visible = false + if result_panel != null: + result_panel.visible = false + if title_screen_root != null: + title_screen_root.visible = true + title_screen_root.move_to_front() + _set_title_settings_visible(false) + _update_title_menu_state() + _refresh_screen_backdrop_visibility() + queue_redraw() + + +func _hide_title_menu() -> void: + title_screen_visible = false + if title_screen_root != null: + title_screen_root.visible = false + _refresh_screen_backdrop_visibility() + + +func _is_title_screen_visible() -> bool: + return title_screen_visible and title_screen_root != null and title_screen_root.visible + + +func _update_title_menu_state() -> void: + var has_load := campaign_state.has_save() or campaign_state.has_manual_save() + if title_load_button != null: + title_load_button.disabled = not has_load + title_load_button.tooltip_text = "저장된 전기를 불러옵니다." if has_load else "저장된 전기가 없습니다." + if title_status_label != null: + title_status_label.text = _format_title_status_text(has_load) + if title_settings_back_button != null: + title_settings_back_button.visible = title_settings_panel != null and title_settings_panel.visible + if title_bgm_toggle != null: + title_bgm_toggle.button_pressed = title_bgm_enabled + if title_sfx_toggle != null: + title_sfx_toggle.button_pressed = title_sfx_enabled + if title_edge_scroll_toggle != null: + title_edge_scroll_toggle.button_pressed = title_edge_scroll_enabled + + +func _format_title_status_text(has_load: bool) -> String: + if not has_load: + return "새 전기를 열어 조조와 하후돈의 첫 군령을 시작합니다." + var summary := campaign_state.get_manual_save_summary() + if campaign_state.has_save(): + var title := campaign_state.get_scenario_title(campaign_state.current_scenario_id) + if title.is_empty(): + title = campaign_state.get_scenario_title(campaign_state.get_start_scenario_id()) + return "이어 할 전기: %s · %d/%d 봉인 · %d냥" % [ + title, + campaign_state.completed_scenarios.size(), + campaign_state.get_scenario_count(), + campaign_state.gold + ] + if not summary.is_empty(): + return "수동 봉인: %s · %d/%d 봉인 · %d냥" % [ + str(summary.get("current_scenario_title", "전기")), + int(summary.get("completed_count", 0)), + int(summary.get("total_count", 0)), + int(summary.get("gold", 0)) + ] + return "저장된 전기가 있습니다." + + +func _on_title_start_pressed() -> void: + _play_ui_confirm() + if not campaign_state.reset_save(campaign_state.get_start_scenario_id()): + if title_status_label != null: + title_status_label.text = "새 전기를 열지 못했습니다. 저장 권한을 확인해 주세요." + return + _enter_campaign_from_title() + + +func _on_title_load_pressed() -> void: + if title_load_button != null and title_load_button.disabled: + return + _play_ui_confirm() + var loaded := false + if campaign_state.has_save(): + loaded = campaign_state.load_game() + elif campaign_state.has_manual_save(): + loaded = campaign_state.load_manual_game() + if not loaded: + if title_status_label != null: + title_status_label.text = "저장된 전기를 열지 못했습니다." + _update_title_menu_state() + return + _enter_campaign_from_title() + + +func _enter_campaign_from_title() -> void: + _hide_title_menu() + if log_box != null: + log_box.clear() + if campaign_state.has_pending_post_battle_choice(): + _load_pending_post_battle_choice() + _update_result_panel() + elif campaign_state.is_campaign_complete(): + _show_campaign_complete() + _update_result_panel() + else: + _load_current_battle() + _show_briefing() + _update_hud() + queue_redraw() + + +func _on_title_settings_pressed() -> void: + _play_ui_click() + _set_title_settings_visible(true) + _update_title_menu_state() + + +func _on_title_settings_back_pressed() -> void: + _play_ui_cancel() + _set_title_settings_visible(false) + _update_title_menu_state() + + +func _set_title_settings_visible(visible: bool) -> void: + if title_settings_panel != null: + title_settings_panel.visible = visible + if title_settings_back_button != null: + title_settings_back_button.visible = visible + + +func _on_title_bgm_toggled(enabled: bool) -> void: + title_bgm_enabled = enabled + if title_bgm_enabled: + _play_bgm(current_bgm_key if not current_bgm_key.is_empty() else "menu") + elif bgm_player != null: + bgm_player.stop() + _update_title_menu_state() + + +func _on_title_sfx_toggled(enabled: bool) -> void: + title_sfx_enabled = enabled + if title_sfx_enabled: + _play_sfx("ui_click", UI_SFX_VOLUME_DB) + _update_title_menu_state() + + +func _on_title_edge_scroll_toggled(enabled: bool) -> void: + title_edge_scroll_enabled = enabled + _update_title_menu_state() + queue_redraw() + + func _update_top_archive_buttons() -> void: var prep_available := ( not battle_started @@ -10544,6 +10878,7 @@ func _sale_price_for_item(item: Dictionary) -> int: func _is_input_locked() -> bool: return ( campaign_complete_screen + or _is_title_screen_visible() or _is_dialogue_visible() or _is_auto_end_turn_prompt_visible() or _has_active_battle_presentation_sequence() diff --git a/tools/smoke_title_menu.gd b/tools/smoke_title_menu.gd new file mode 100644 index 0000000..9a777fb --- /dev/null +++ b/tools/smoke_title_menu.gd @@ -0,0 +1,112 @@ +extends SceneTree + +const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") + +const SAVE_PATH := "user://campaign_save.json" +const MANUAL_SAVE_PATH := "user://campaign_manual_save.json" + + +func _init() -> void: + var save_backup := _backup_user_file(SAVE_PATH) + var manual_backup := _backup_user_file(MANUAL_SAVE_PATH) + var failures: Array[String] = [] + _clear_user_file(SAVE_PATH) + _clear_user_file(MANUAL_SAVE_PATH) + + _check_title_menu_start_load_and_settings(failures) + + if not _restore_user_file(SAVE_PATH, save_backup): + failures.append("could not restore campaign save after title menu smoke") + if not _restore_user_file(MANUAL_SAVE_PATH, manual_backup): + failures.append("could not restore manual campaign save after title menu smoke") + + if failures.is_empty(): + print("title menu smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_title_menu_start_load_and_settings(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._ready() + + if scene.title_screen_root == null or not scene.title_screen_root.visible: + failures.append("title screen should be visible on startup") + if scene.briefing_panel == null or scene.briefing_panel.visible: + failures.append("briefing should stay hidden behind the title screen") + if scene.title_start_button == null or scene.title_start_button.text != "처음부터 시작": + failures.append("title screen should expose Start from Beginning") + if scene.title_load_button == null or scene.title_load_button.text != "게임 로드": + failures.append("title screen should expose Game Load") + elif not scene.title_load_button.disabled: + failures.append("Game Load should be disabled when no save exists") + if scene.title_settings_button == null or scene.title_settings_button.text != "환경 설정": + failures.append("title screen should expose Settings") + + scene._on_title_settings_pressed() + if scene.title_settings_panel == null or not scene.title_settings_panel.visible: + failures.append("title settings panel should open from the title screen") + if scene.title_bgm_toggle == null or scene.title_sfx_toggle == null or scene.title_edge_scroll_toggle == null: + failures.append("title settings should expose BGM, SFX, and edge-scroll toggles") + scene._on_title_bgm_toggled(false) + if scene.title_bgm_enabled: + failures.append("BGM toggle should disable background music") + scene._on_title_sfx_toggled(false) + if scene.title_sfx_enabled: + failures.append("SFX toggle should disable sound effects") + scene._on_title_edge_scroll_toggled(false) + if scene.title_edge_scroll_enabled: + failures.append("edge-scroll toggle should disable edge scrolling") + if scene._update_edge_scroll(0.25): + failures.append("edge scrolling should not move while disabled in settings") + scene._on_title_settings_back_pressed() + if scene.title_settings_panel != null and scene.title_settings_panel.visible: + failures.append("settings back should hide the title settings panel") + + scene._on_title_start_pressed() + if scene.title_screen_root != null and scene.title_screen_root.visible: + failures.append("starting a new game should hide the title screen") + if scene.briefing_panel == null or not scene.briefing_panel.visible: + failures.append("starting a new game should enter the first briefing") + if not scene.campaign_state.has_save(): + failures.append("starting a new game should create the main campaign save") + + scene._show_title_menu() + scene._update_title_menu_state() + if scene.title_load_button == null or scene.title_load_button.disabled: + failures.append("Game Load should become available after a campaign save exists") + scene._on_title_load_pressed() + if scene.title_screen_root != null and scene.title_screen_root.visible: + failures.append("loading a saved game should hide the title screen") + if scene.briefing_panel == null or not scene.briefing_panel.visible: + failures.append("loading a saved game should enter the current campaign briefing") + scene.free() + + +func _backup_user_file(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"exists": false, "text": ""} + return { + "exists": true, + "text": FileAccess.get_file_as_string(path) + } + + +func _restore_user_file(path: String, backup: Dictionary) -> bool: + if bool(backup.get("exists", false)): + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return false + file.store_string(str(backup.get("text", ""))) + return true + return _clear_user_file(path) + + +func _clear_user_file(path: String) -> bool: + if not FileAccess.file_exists(path): + return true + return DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK