From ddec5b5e94f1a5f6ba32aac569725bfee5af9f5d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 21 Jun 2026 01:45:30 +0900 Subject: [PATCH] Clarify combat result reports --- scripts/core/battle_state.gd | 75 +++++++++++++- scripts/scenes/battle_scene.gd | 150 +++++++++++++++++++++++++++ tools/capture_readability_frames.gd | 40 +++++++ tools/smoke_post_move_action_flow.gd | 8 ++ tools/smoke_visual_assets.gd | 29 ++++++ 5 files changed, 298 insertions(+), 4 deletions(-) diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 6af989f..3fd1c5b 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -7,6 +7,7 @@ signal changed signal log_added(message: String) signal dialogue_requested(lines: Array) signal combat_feedback_requested(unit_id: String, text: String, kind: String) +signal combat_result_reported(report: Dictionary) signal unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) signal unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) signal objective_updated(victory: String, defeat: String) @@ -3752,19 +3753,40 @@ func _opposing_team(team: String) -> String: func _resolve_combat(attacker: Dictionary, target: Dictionary) -> void: + var report := _make_combat_result_report(attacker, target) var attack_result := _resolve_attack(attacker, target, false) + (report["entries"] as Array).append(attack_result) var attack_exp := EXP_ATTACK if bool(attack_result.get("hit", false)) else EXP_MISS if bool(attack_result.get("defeated", false)) or battle_status != STATUS_ACTIVE: - _grant_experience(attacker, attack_exp + (EXP_DEFEAT_BONUS if bool(attack_result.get("defeated", false)) else 0)) + var final_attack_exp := attack_exp + (EXP_DEFEAT_BONUS if bool(attack_result.get("defeated", false)) else 0) + _grant_experience(attacker, final_attack_exp) + report["attacker_merit"] = final_attack_exp + combat_result_reported.emit(report) return var counter_exp := 0 if bool(target.get("controllable", true)) and _is_in_attack_range(target, attacker["pos"]): var counter_result := _resolve_attack(target, attacker, true) + (report["entries"] as Array).append(counter_result) counter_exp = (EXP_COUNTER if bool(counter_result.get("hit", false)) else EXP_MISS) + (EXP_DEFEAT_BONUS if bool(counter_result.get("defeated", false)) else 0) _grant_experience(attacker, attack_exp) + report["attacker_merit"] = attack_exp _grant_experience(target, counter_exp) + report["counter_merit"] = counter_exp + combat_result_reported.emit(report) + + +func _make_combat_result_report(attacker: Dictionary, target: Dictionary) -> Dictionary: + return { + "attacker_id": str(attacker.get("id", "")), + "attacker_name": str(attacker.get("name", "")), + "target_id": str(target.get("id", "")), + "target_name": str(target.get("name", "")), + "attacker_merit": 0, + "counter_merit": 0, + "entries": [] + } func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := false) -> Dictionary: @@ -3780,7 +3802,22 @@ func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := fal if rng.randi_range(1, 100) > hit_chance: _emit_log("%s %s %s, 헛쳤다. 명중 %d%%." % [attacker["name"], verb, target["name"], hit_chance]) _emit_combat_feedback(target, "헛침", "miss") - return {"hit": false, "defeated": false} + return { + "attacker_id": str(attacker.get("id", "")), + "attacker_name": str(attacker.get("name", "")), + "target_id": str(target.get("id", "")), + "target_name": str(target.get("name", "")), + "is_counter": is_counter, + "hit": false, + "defeated": false, + "damage": 0, + "hit_chance": hit_chance, + "target_hp": int(target.get("hp", 0)), + "target_max_hp": int(target.get("max_hp", 0)), + "directional_label": "", + "directional_bonus": 0, + "effective_bonus": 0 + } var directional_info := get_directional_attack_info(attacker, target) var damage := calculate_damage(attacker, target) target["hp"] = max(0, int(target["hp"]) - damage) @@ -3799,8 +3836,38 @@ func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := fal _emit_log("%s 퇴각." % target["name"]) _emit_combat_feedback(target, "퇴각", "defeat") _handle_unit_defeated(target) - return {"hit": true, "defeated": true} - return {"hit": true, "defeated": false} + return { + "attacker_id": str(attacker.get("id", "")), + "attacker_name": str(attacker.get("name", "")), + "target_id": str(target.get("id", "")), + "target_name": str(target.get("name", "")), + "is_counter": is_counter, + "hit": true, + "defeated": true, + "damage": damage, + "hit_chance": hit_chance, + "target_hp": int(target.get("hp", 0)), + "target_max_hp": int(target.get("max_hp", 0)), + "directional_label": str(directional_info.get("label", "")), + "directional_bonus": directional_bonus, + "effective_bonus": effective_bonus + } + return { + "attacker_id": str(attacker.get("id", "")), + "attacker_name": str(attacker.get("name", "")), + "target_id": str(target.get("id", "")), + "target_name": str(target.get("name", "")), + "is_counter": is_counter, + "hit": true, + "defeated": false, + "damage": damage, + "hit_chance": hit_chance, + "target_hp": int(target.get("hp", 0)), + "target_max_hp": int(target.get("max_hp", 0)), + "directional_label": str(directional_info.get("label", "")), + "directional_bonus": directional_bonus, + "effective_bonus": effective_bonus + } func _resolve_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> bool: diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 0282b7d..2042141 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -100,6 +100,7 @@ const AUDIO_PREVIEW_LABEL := "확인음 · 발소리 · 타격음 · 퇴각음 const FLOATING_TEXT_LIFETIME := 1.0 const FLOATING_TEXT_RISE := 42.0 const OBJECTIVE_NOTICE_DURATION := 2.6 +const COMBAT_RESULT_NOTICE_DURATION := 2.8 const EDGE_SCROLL_MARGIN := 48.0 const EDGE_SCROLL_OUTER_MARGIN := 28.0 const EDGE_SCROLL_MIN_SPEED := 80.0 @@ -194,6 +195,8 @@ const OPENING_STORY_PROGRESS_LEDGER_SIZE := Vector2(326, 48) const OPENING_STORY_PROGRESS_SEAL_SIZE := Vector2(34, 34) const COMMAND_NOTICE_ICON_SIZE := Vector2(32, 32) const COMMAND_NOTICE_MAX_ICONS := 5 +const COMBAT_RESULT_PANEL_POSITION := Vector2(838, 430) +const COMBAT_RESULT_PANEL_SIZE := Vector2(396, 128) const CHAPTER_SEAL_BADGE_SIZE := Vector2(54, 54) const CHAPTER_SCENARIO_THUMBNAIL_SIZE := Vector2(72, 50) const CHAPTER_INFO_BADGE_SIZE := Vector2(86, 22) @@ -479,6 +482,10 @@ var mission_detail_label: Label var objective_notice_panel: PanelContainer var objective_notice_icon_strip: HBoxContainer var objective_notice_label: Label +var combat_result_panel: PanelContainer +var combat_result_title_label: Label +var combat_result_primary_label: Label +var combat_result_detail_label: Label var ui_root_control: Control var screen_backdrop: ColorRect var hud_unit_portrait_panel: PanelContainer @@ -703,7 +710,9 @@ var audio_stream_cache := {} var recent_sfx_keys: Array[String] = [] var last_announced_battle_status := "" var floating_texts: Array[Dictionary] = [] +var pending_combat_result_report: Dictionary = {} var objective_notice_timer := 0.0 +var combat_result_notice_timer := 0.0 var battle_start_notice_pending := false var unit_motion_by_unit: Dictionary = {} var unit_action_motion_by_unit: Dictionary = {} @@ -730,6 +739,7 @@ func _ready() -> void: state.log_added.connect(_on_log_added) state.dialogue_requested.connect(_on_dialogue_requested) state.combat_feedback_requested.connect(_on_combat_feedback_requested) + state.combat_result_reported.connect(_on_combat_result_reported) state.unit_motion_requested.connect(_on_unit_motion_requested) state.unit_action_motion_requested.connect(_on_unit_action_motion_requested) state.objective_updated.connect(_on_objective_updated) @@ -3693,6 +3703,44 @@ func _create_hud() -> void: objective_notice_column.add_child(objective_notice_label) objective_notice_column.add_child(_make_scroll_rod(548, 3)) + combat_result_panel = PanelContainer.new() + combat_result_panel.name = "CombatResultNotice" + combat_result_panel.visible = false + combat_result_panel.mouse_filter = Control.MOUSE_FILTER_PASS + combat_result_panel.position = COMBAT_RESULT_PANEL_POSITION + combat_result_panel.size = COMBAT_RESULT_PANEL_SIZE + combat_result_panel.custom_minimum_size = COMBAT_RESULT_PANEL_SIZE + combat_result_panel.z_as_relative = false + combat_result_panel.z_index = 73 + _apply_panel_style(combat_result_panel, "hud_readable") + root.add_child(combat_result_panel) + + var combat_result_column := VBoxContainer.new() + combat_result_column.add_theme_constant_override("separation", 5) + combat_result_panel.add_child(combat_result_column) + + combat_result_title_label = Label.new() + combat_result_title_label.text = "전공 보고" + combat_result_title_label.custom_minimum_size = Vector2(360, 24) + combat_result_title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + combat_result_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + _apply_label_style(combat_result_title_label, UI_PARCHMENT_TEXT, 15) + combat_result_column.add_child(combat_result_title_label) + + combat_result_primary_label = Label.new() + combat_result_primary_label.custom_minimum_size = Vector2(360, 30) + combat_result_primary_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + combat_result_primary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _apply_label_style(combat_result_primary_label, UI_RICE_PAPER_LIGHT, 15) + combat_result_column.add_child(combat_result_primary_label) + + combat_result_detail_label = Label.new() + combat_result_detail_label.custom_minimum_size = Vector2(360, 44) + combat_result_detail_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP + combat_result_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _apply_label_style(combat_result_detail_label, UI_PARCHMENT_TEXT, 13) + combat_result_column.add_child(combat_result_detail_label) + auto_end_turn_prompt_panel = PanelContainer.new() auto_end_turn_prompt_panel.name = "AutoEndTurnPrompt" auto_end_turn_prompt_panel.visible = false @@ -6124,6 +6172,10 @@ func _process(delta: float) -> void: objective_notice_timer = maxf(0.0, objective_notice_timer - delta) if objective_notice_timer <= 0.0 and objective_notice_panel != null: objective_notice_panel.visible = false + if combat_result_notice_timer > 0.0: + combat_result_notice_timer = maxf(0.0, combat_result_notice_timer - delta) + if combat_result_notice_timer <= 0.0 and combat_result_panel != null: + combat_result_panel.visible = false if not floating_texts.is_empty(): var active_texts: Array[Dictionary] = [] @@ -12545,6 +12597,9 @@ func _on_dialogue_requested(lines: Array) -> void: var normalized_lines := _normalized_dialogue_lines(lines) if normalized_lines.is_empty(): return + if combat_result_panel != null: + combat_result_panel.visible = false + combat_result_notice_timer = 0.0 dialogue_queue.append(normalized_lines) if _can_show_dialogue_now(): _show_next_dialogue() @@ -12580,6 +12635,100 @@ func _on_combat_feedback_requested(unit_id: String, text: String, kind: String) queue_redraw() +func _on_combat_result_reported(report: Dictionary) -> void: + if report.is_empty(): + return + if _is_dialogue_visible() or not dialogue_queue.is_empty(): + pending_combat_result_report = report.duplicate(true) + return + _show_combat_result_report(report) + + +func _show_combat_result_report(report: Dictionary) -> void: + if combat_result_panel == null or report.is_empty(): + return + var lines := _format_combat_result_report_lines(report) + if lines.is_empty(): + return + combat_result_title_label.text = "전공 보고" + combat_result_primary_label.text = lines[0] + combat_result_primary_label.tooltip_text = _join_strings(lines, "\n") + combat_result_detail_label.text = _join_strings(lines.slice(1), "\n") + combat_result_detail_label.tooltip_text = combat_result_primary_label.tooltip_text + combat_result_panel.tooltip_text = combat_result_primary_label.tooltip_text + combat_result_panel.visible = true + combat_result_notice_timer = COMBAT_RESULT_NOTICE_DURATION + combat_result_panel.move_to_front() + + +func _show_pending_combat_result_if_possible() -> void: + if pending_combat_result_report.is_empty() or _is_dialogue_visible() or not dialogue_queue.is_empty(): + return + var report := pending_combat_result_report.duplicate(true) + pending_combat_result_report.clear() + _show_combat_result_report(report) + + +func _format_combat_result_report_lines(report: Dictionary) -> Array[String]: + var lines: Array[String] = [] + var entries: Array = report.get("entries", []) + for entry_value in entries: + if typeof(entry_value) != TYPE_DICTIONARY: + continue + var entry := entry_value as Dictionary + var line := _format_combat_result_entry(entry) + if not line.is_empty(): + lines.append(line) + if int(report.get("attacker_merit", 0)) > 0: + lines.append("%s 공적 +%d" % [str(report.get("attacker_name", "아군")), int(report.get("attacker_merit", 0))]) + if int(report.get("counter_merit", 0)) > 0: + var counter_name := _combat_result_counter_name(report) + lines.append("%s 공적 +%d" % [counter_name, int(report.get("counter_merit", 0))]) + return lines + + +func _format_combat_result_entry(entry: Dictionary) -> String: + var attacker_name := str(entry.get("attacker_name", "부대")) + var target_name := str(entry.get("target_name", "적")) + var verb := "반격" if bool(entry.get("is_counter", false)) else "공격" + if not bool(entry.get("hit", false)): + return "%s %s %s · 헛침 · 명중 %d%%" % [ + attacker_name, + verb, + target_name, + int(entry.get("hit_chance", 0)) + ] + var parts: Array[String] = [ + "%s %s %s" % [attacker_name, verb, target_name], + "%d 피해" % int(entry.get("damage", 0)) + ] + var directional_bonus := int(entry.get("directional_bonus", 0)) + if directional_bonus > 0: + parts.append("%s +%d" % [str(entry.get("directional_label", "방향")), directional_bonus]) + var effective_bonus := int(entry.get("effective_bonus", 0)) + if effective_bonus > 0: + parts.append("특효 +%d" % effective_bonus) + if bool(entry.get("defeated", false)): + parts.append("퇴각") + else: + var target_hp := int(entry.get("target_hp", 0)) + var target_max_hp := int(entry.get("target_max_hp", 0)) + if target_max_hp > 0: + parts.append("잔여 %d/%d" % [target_hp, target_max_hp]) + return _join_strings(parts, " · ") + + +func _combat_result_counter_name(report: Dictionary) -> String: + var entries: Array = report.get("entries", []) + for entry_value in entries: + if typeof(entry_value) != TYPE_DICTIONARY: + continue + var entry := entry_value as Dictionary + if bool(entry.get("is_counter", false)): + return str(entry.get("attacker_name", report.get("target_name", "반격 부대"))) + return str(report.get("target_name", "반격 부대")) + + func _combat_feedback_sfx_key(text: String, kind: String) -> String: if kind == "reinforcement" or text == "증원": return "footstep" @@ -13093,6 +13242,7 @@ func _advance_dialogue() -> void: _show_next_dialogue() else: _show_battle_start_notice_if_ready() + _show_pending_combat_result_if_possible() _update_hud() queue_redraw() diff --git a/tools/capture_readability_frames.gd b/tools/capture_readability_frames.gd index 02bcfb7..20fd01c 100644 --- a/tools/capture_readability_frames.gd +++ b/tools/capture_readability_frames.gd @@ -123,6 +123,10 @@ func _capture_viewport_set(spec: Dictionary, output_dir: String, captures: Array await _settle() await _capture_frame(scene, output_dir, label, "12_post_move_item_picker", captures, failures) + _prepare_combat_result_frame(scene) + await _settle() + await _capture_frame(scene, output_dir, label, "13_battle_combat_result", captures, failures) + scene.queue_free() await _settle() @@ -194,12 +198,48 @@ func _prepare_post_move_action_frame(scene, picker_mode: String) -> void: scene.queue_redraw() +func _prepare_combat_result_frame(scene) -> void: + scene._hide_command_hint() + scene._hide_post_move_menu() + scene._hide_post_move_picker() + scene._hide_tactic_menu() + scene._hide_item_menu() + scene._hide_equip_menu() + scene._clear_pending_move_state() + scene.selected_skill_id = "" + scene.selected_item_id = "" + scene.basic_attack_targeting = false + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") + if cao_cao.is_empty() or enemy.is_empty(): + return + cao_cao["pos"] = Vector2i(2, 3) + cao_cao["moved"] = false + cao_cao["acted"] = false + cao_cao["atk"] = maxi(48, int(cao_cao.get("atk", 0))) + cao_cao["agi"] = 999 + enemy["pos"] = Vector2i(3, 3) + enemy["alive"] = true + enemy["hp"] = 90 + enemy["max_hp"] = 90 + enemy["def"] = 1 + scene.state.rng.seed = 1 + scene.state.select_unit("cao_cao") + scene._refresh_ranges() + scene._focus_board_on_cell(Vector2i(3, 3)) + scene.state.try_attack_selected("yellow_turban_1") + scene._update_hud() + scene.queue_redraw() + + func _screen_for_cell(scene, cell: Vector2i) -> Vector2: var rect: Rect2 = scene._rect_for_cell(cell) return rect.position + rect.size * 0.5 func _capture_frame(_scene, output_dir: String, viewport_label: String, frame_label: String, captures: Array[Dictionary], failures: Array[String]) -> void: + Input.warp_mouse(Vector2(12, 12)) + await _settle() var texture := get_root().get_texture() if texture == null: failures.append("viewport texture missing for %s/%s" % [viewport_label, frame_label]) diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd index c57774d..205f310 100644 --- a/tools/smoke_post_move_action_flow.gd +++ b/tools/smoke_post_move_action_flow.gd @@ -619,6 +619,8 @@ func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void: if unit_id == "cao_cao" and kind == "progress": progress_feedback.append(text) ) + if not scene.state.combat_result_reported.is_connected(scene._on_combat_result_reported): + scene.state.combat_result_reported.connect(scene._on_combat_result_reported) scene._on_post_move_attack_pressed() var enemy_hp_before := int(enemy.get("hp", 0)) scene._handle_board_click(_screen_for_cell(Vector2i(3, 3))) @@ -636,6 +638,12 @@ func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void: failures.append("attack click should commit deferred movement event before resolving attack") if not _feedback_contains(progress_feedback, "공적 +"): failures.append("attack click should show visible merit feedback over the unit: %s" % str(progress_feedback)) + if scene.combat_result_panel == null or not scene.combat_result_panel.visible: + failures.append("attack click should show a compact combat result report") + else: + var result_text := "%s\n%s" % [scene.combat_result_primary_label.text, scene.combat_result_detail_label.text] + if not result_text.contains("공격") or not result_text.contains("피해") or not result_text.contains("공적 +"): + failures.append("combat result report should summarize damage and merit: %s" % result_text) scene.free() diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 7f7a825..6b1abaa 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3881,6 +3881,35 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: _check_panel_style_frame(failures, scene.cell_info_panel, "HUD terrain info panel", Color(0.42, 0.35, 0.24, 0.98), 2) _check_panel_style_fill(failures, scene.forecast_panel, "HUD forecast info panel", Color(0.060, 0.056, 0.048, 0.982)) _check_panel_style_frame(failures, scene.forecast_panel, "HUD forecast info panel", Color(0.42, 0.35, 0.24, 0.98), 2) + _check_panel_style_fill(failures, scene.combat_result_panel, "combat result readable frame", Color(0.060, 0.056, 0.048, 0.982)) + _check_panel_style_frame(failures, scene.combat_result_panel, "combat result readable frame", Color(0.42, 0.35, 0.24, 0.98), 2) + if scene.combat_result_panel == null or scene.combat_result_panel.custom_minimum_size != BattleSceneScript.COMBAT_RESULT_PANEL_SIZE: + failures.append("combat result report should reserve a stable QHD layout size") + scene._on_combat_result_reported({ + "attacker_name": "조조", + "target_name": "황건병", + "attacker_merit": 10, + "counter_merit": 0, + "entries": [{ + "attacker_name": "조조", + "target_name": "황건병", + "is_counter": false, + "hit": true, + "defeated": false, + "damage": 18, + "hit_chance": 92, + "target_hp": 12, + "target_max_hp": 30, + "directional_bonus": 2, + "directional_label": "후방 공격", + "effective_bonus": 0 + }] + }) + if scene.combat_result_panel == null or not scene.combat_result_panel.visible: + failures.append("combat result report should appear after a battle result signal") + elif not scene.combat_result_primary_label.text.contains("피해") or not scene.combat_result_detail_label.text.contains("공적 +10"): + failures.append("combat result report should show damage and merit in readable Korean: %s / %s" % [scene.combat_result_primary_label.text, scene.combat_result_detail_label.text]) + scene.combat_result_panel.visible = false _check_panel_uses_flat_readable_backing(failures, scene.title_load_panel, "title load readable panel") _check_panel_uses_flat_readable_backing(failures, scene.title_settings_panel, "title settings readable panel") _check_panel_uses_flat_readable_backing(failures, _find_descendant_by_name(scene.ui_root_control, "SettingsPanel") as PanelContainer, "battle settings readable panel")