From 5132d2b4eb5f8e46ec09ca6c4d38fcb9e4cea0c1 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 23:57:44 +0900 Subject: [PATCH] Add visual reward strip to results --- README.md | 1 + scripts/scenes/battle_scene.gd | 198 ++++++++++++++++++++++++++++++++- tools/smoke_result_summary.gd | 32 ++++++ 3 files changed, 229 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8904d9b..1f46f9a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Pre-battle Chapters overview shows story-arc progress and can open completed or current battles. - Pre-battle Save menu can write and load one manual campaign checkpoint separate from the automatic save. - The title screen exposes Start, Load, and Settings, and Load now lets the player choose between the automatic record and the manual checkpoint. +- Victory results show compact reward icons for gold, items, and officer roster changes, with hover details for the full reward text. - Escort scenarios can include required, non-controllable protected units. - Campaign saves joined officers, future deployments can require prior recruitment, and choices can make officers leave. - Completed saves reconcile officer join/leave rewards on load without replaying gold or items. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 8ddc394..b84f2e8 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -119,6 +119,7 @@ const BRIEFING_CAMP_THUMBNAIL_SIZE := Vector2(188, 86) const CAMP_TALK_PORTRAIT_SIZE := Vector2(58, 58) const SHOP_MERCHANT_BADGE_SIZE := Vector2(50, 50) const SHOP_ITEM_ICON_SIZE := Vector2(48, 48) +const RESULT_REWARD_ICON_SIZE := Vector2(48, 48) const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104) const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100) const HUD_UNIT_INFO_SIZE := Vector2(320, 126) @@ -428,7 +429,9 @@ var dialogue_progress_label: Label var dialogue_previous_button: Button var dialogue_continue_button: Button var result_panel: PanelContainer +var result_reward_strip: HBoxContainer var result_label: Label +var result_text_scroll: ScrollContainer var result_seal_ribbon: HBoxContainer var result_choice_list: VBoxContainer var result_restart_button: Button @@ -3261,15 +3264,36 @@ func _create_hud() -> void: result_body_panel.add_child(result_body_row) result_body_row.add_child(_make_edict_marker_stack(["", "", ""], 198)) + var result_middle_column := VBoxContainer.new() + result_middle_column.custom_minimum_size = Vector2(436, 198) + result_middle_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + result_middle_column.size_flags_vertical = Control.SIZE_EXPAND_FILL + result_middle_column.add_theme_constant_override("separation", 8) + result_body_row.add_child(result_middle_column) + + result_reward_strip = HBoxContainer.new() + result_reward_strip.visible = false + result_reward_strip.custom_minimum_size = Vector2(436, 52) + result_reward_strip.add_theme_constant_override("separation", 8) + result_reward_strip.tooltip_text = "이번 전투에서 정리된 전리품과 명부 변화입니다." + result_middle_column.add_child(result_reward_strip) + + result_text_scroll = ScrollContainer.new() + result_text_scroll.custom_minimum_size = Vector2(436, 138) + result_text_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + result_text_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + result_text_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + result_middle_column.add_child(result_text_scroll) + result_label = Label.new() result_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT result_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP result_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - result_label.custom_minimum_size = Vector2(436, 198) + result_label.custom_minimum_size = Vector2(420, 138) result_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL result_label.size_flags_vertical = Control.SIZE_EXPAND_FILL _apply_label_style(result_label, UI_AGED_INK, 16) - result_body_row.add_child(result_label) + result_text_scroll.add_child(result_label) result_body_row.add_child(_make_edict_marker_stack(["", "", ""], 198)) result_column.add_child(_make_scroll_rod(520, 3)) @@ -8377,6 +8401,7 @@ func _update_result_panel() -> void: if campaign_complete_screen: _clear_dialogue() _clear_result_choices() + _clear_result_reward_strip() _apply_panel_style(result_panel, "result_victory_tablet") result_panel.visible = true if result_restart_button != null: @@ -8404,6 +8429,7 @@ func _update_result_panel() -> void: _apply_panel_style(result_panel, "result_victory_tablet") result_panel.visible = true _rebuild_result_choices() + _rebuild_result_reward_strip() if result_restart_button != null: result_restart_button.visible = true _update_result_next_button_visibility() @@ -8411,6 +8437,7 @@ func _update_result_panel() -> void: elif state.battle_status == BattleState.STATUS_DEFEAT: _clear_dialogue() _clear_result_choices() + _clear_result_reward_strip() _apply_panel_style(result_panel, "result_defeat_tablet") result_panel.visible = true if result_restart_button != null: @@ -8420,6 +8447,7 @@ func _update_result_panel() -> void: result_label.text = _format_defeat_result_text() else: _clear_result_choices() + _clear_result_reward_strip() result_panel.visible = false if result_restart_button != null: result_restart_button.visible = true @@ -8428,6 +8456,172 @@ func _update_result_panel() -> void: _refresh_screen_backdrop_visibility() +func _clear_result_reward_strip() -> void: + if result_reward_strip == null: + return + for child in result_reward_strip.get_children(): + result_reward_strip.remove_child(child) + child.queue_free() + result_reward_strip.visible = false + + +func _rebuild_result_reward_strip() -> void: + _clear_result_reward_strip() + if result_reward_strip == null or battle_result_summary.is_empty(): + return + if bool(battle_result_summary.get("pending_choice", false)): + return + + if bool(battle_result_summary.get("already_completed", false)): + result_reward_strip.add_child(_make_result_reward_text_chip("수령", "이미 수령한 전리품입니다.")) + elif bool(battle_result_summary.get("rewards_applied", false)): + var gold_reward := int(battle_result_summary.get("gold", 0)) + if gold_reward > 0: + result_reward_strip.add_child(_make_result_reward_text_chip("냥", "군자금 +%d냥" % gold_reward)) + var item_counts := _result_item_counts(battle_result_summary.get("items", [])) + var item_ids := item_counts.keys() + item_ids.sort() + for item_id in item_ids: + var key := str(item_id) + result_reward_strip.add_child(_make_result_reward_item_chip(key, int(item_counts.get(key, 0)))) + else: + result_reward_strip.add_child(_make_result_reward_text_chip("보류", "전리품이 아직 기록되지 않았습니다.")) + + var joined_officers: Array = battle_result_summary.get("joined_officers", []) + for officer_id in joined_officers: + result_reward_strip.add_child(_make_result_reward_officer_chip(str(officer_id), true)) + var left_officers: Array = battle_result_summary.get("left_officers", []) + for officer_id in left_officers: + result_reward_strip.add_child(_make_result_reward_officer_chip(str(officer_id), false)) + + result_reward_strip.visible = result_reward_strip.get_child_count() > 0 + + +func _result_item_counts(values) -> Dictionary: + var counts := {} + if typeof(values) != TYPE_ARRAY: + return counts + for value in values: + var key := str(value) + if key.is_empty(): + continue + counts[key] = int(counts.get(key, 0)) + 1 + return counts + + +func _make_result_reward_text_chip(label_text: String, tooltip_text: String) -> PanelContainer: + var panel := _make_result_reward_chip_shell(tooltip_text) + var label := Label.new() + label.set_anchors_preset(Control.PRESET_FULL_RECT) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.text = label_text + label.tooltip_text = tooltip_text + _apply_label_style(label, UI_PARCHMENT_TEXT, 14) + panel.add_child(label) + return panel + + +func _make_result_reward_item_chip(item_id: String, count: int) -> PanelContainer: + var item := state.get_item_def(item_id) + var display_name := _item_inventory_display_name(item_id) + var detail := _format_shop_item_effect_text(item) + var tooltip := "물자: %s %d점" % [display_name, count] + if not detail.is_empty(): + tooltip += "\n%s" % detail + var panel := _make_result_reward_chip_shell(tooltip) + var stack := Control.new() + stack.set_anchors_preset(Control.PRESET_FULL_RECT) + stack.tooltip_text = tooltip + panel.add_child(stack) + + var icon_rect := TextureRect.new() + icon_rect.set_anchors_preset(Control.PRESET_FULL_RECT) + icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_rect.texture = _load_art_texture(str(item.get("icon", ""))) + icon_rect.visible = icon_rect.texture != null + icon_rect.tooltip_text = tooltip + stack.add_child(icon_rect) + + var fallback_label := Label.new() + fallback_label.set_anchors_preset(Control.PRESET_FULL_RECT) + fallback_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + fallback_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + fallback_label.text = _dialogue_portrait_initials(display_name) + fallback_label.visible = icon_rect.texture == null + fallback_label.tooltip_text = tooltip + _apply_label_style(fallback_label, UI_PARCHMENT_TEXT, 16) + stack.add_child(fallback_label) + + if count > 1: + stack.add_child(_make_result_reward_count_badge(count, tooltip)) + return panel + + +func _make_result_reward_officer_chip(officer_id: String, joined: bool) -> PanelContainer: + var officer_name := _officer_display_name_from_id(officer_id) + var action_text := "합류" if joined else "이탈" + var tooltip := "%s: %s" % [action_text, officer_name] + var panel := _make_result_reward_chip_shell(tooltip) + var stack := Control.new() + stack.set_anchors_preset(Control.PRESET_FULL_RECT) + stack.tooltip_text = tooltip + panel.add_child(stack) + + var portrait := TextureRect.new() + portrait.set_anchors_preset(Control.PRESET_FULL_RECT) + portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + portrait.texture = _load_art_texture(state.data_catalog.get_portrait_for_officer(officer_id)) + portrait.visible = portrait.texture != null + portrait.tooltip_text = tooltip + stack.add_child(portrait) + + var fallback_label := Label.new() + fallback_label.set_anchors_preset(Control.PRESET_FULL_RECT) + fallback_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + fallback_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + fallback_label.text = _dialogue_portrait_initials(officer_name) + fallback_label.visible = portrait.texture == null + fallback_label.tooltip_text = tooltip + _apply_label_style(fallback_label, UI_PARCHMENT_TEXT, 16) + stack.add_child(fallback_label) + + var badge_text := "+" if joined else "-" + stack.add_child(_make_result_reward_corner_badge(badge_text, tooltip)) + return panel + + +func _make_result_reward_chip_shell(tooltip_text: String) -> PanelContainer: + var panel := PanelContainer.new() + panel.custom_minimum_size = RESULT_REWARD_ICON_SIZE + panel.mouse_filter = Control.MOUSE_FILTER_STOP + panel.tooltip_text = tooltip_text + _apply_panel_style(panel, "portrait") + return panel + + +func _make_result_reward_count_badge(count: int, tooltip_text: String) -> Label: + return _make_result_reward_corner_badge("x%d" % count, tooltip_text) + + +func _make_result_reward_corner_badge(text: String, tooltip_text: String) -> Label: + var badge := Label.new() + badge.text = text + badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + badge.position = Vector2(25, 28) + badge.size = Vector2(23, 18) + badge.add_theme_color_override("font_color", UI_PARCHMENT_TEXT) + badge.add_theme_color_override("font_shadow_color", UI_AGED_INK) + badge.add_theme_constant_override("shadow_offset_x", 1) + badge.add_theme_constant_override("shadow_offset_y", 1) + badge.add_theme_font_size_override("font_size", 12) + badge.tooltip_text = tooltip_text + return badge + + func _format_victory_result_text() -> String: var lines := ["승리"] _append_edict_clause(lines, "승리 조건", "완료", str(state.objectives.get("victory", ""))) diff --git a/tools/smoke_result_summary.gd b/tools/smoke_result_summary.gd index e112a1b..5f06c9e 100644 --- a/tools/smoke_result_summary.gd +++ b/tools/smoke_result_summary.gd @@ -47,6 +47,18 @@ func _init() -> void: _check_contains(failures, "next camp shop", result_text, "군상 물품 · 6종") _check_contains(failures, "next camp deploy", result_text, "출진 명부 · 2/4") _check_contains(failures, "next camp formation", result_text, "진형 배치 6칸") + scene._create_hud() + scene._rebuild_result_reward_strip() + if scene.result_reward_strip == null or not scene.result_reward_strip.visible: + failures.append("victory result should show a compact reward icon strip") + elif scene.result_reward_strip.get_child_count() < 3: + failures.append("reward icon strip should include gold, item, and officer chips") + var reward_tooltips := _collect_tooltips(scene.result_reward_strip) + _check_contains(failures, "reward strip gold tooltip", reward_tooltips, "군자금 +100냥") + _check_contains(failures, "reward strip item tooltip", reward_tooltips, "물자: 콩 2점") + _check_contains(failures, "reward strip officer tooltip", reward_tooltips, "합류: 전위") + if not _has_texture_rect(scene.result_reward_strip): + failures.append("reward icon strip should reuse item or officer artwork") scene.battle_result_summary = { "saved": false, @@ -129,3 +141,23 @@ func _check_not_contains(failures: Array[String], label: String, text: String, u if not text.contains(unexpected): return failures.append("%s did not expect `%s` in `%s`" % [label, unexpected, text]) + + +func _collect_tooltips(root: Node) -> String: + var parts := [] + if root is Control: + var tooltip := (root as Control).tooltip_text + if not tooltip.is_empty(): + parts.append(tooltip) + for child in root.get_children(): + parts.append(_collect_tooltips(child)) + return "\n".join(parts) + + +func _has_texture_rect(root: Node) -> bool: + if root is TextureRect and (root as TextureRect).texture != null: + return true + for child in root.get_children(): + if _has_texture_rect(child): + return true + return false