From 7d4827201fd07d729809af52c77964bb687bb3e9 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 21:02:39 +0900 Subject: [PATCH] Show auto attack forecasts in battle HUD --- docs/ARCHITECTURE.md | 2 +- scripts/scenes/battle_scene.gd | 45 ++++++++++++++++++++++++++++ tools/smoke_post_move_action_flow.gd | 22 ++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f79f33..d65c943 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,7 +39,7 @@ 4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, selectable camp Talk conversations, manual checkpoint menu, and optional pre-battle shop, Armory, Roster, and Formation setup. 5. Player selects a unit. 6. Briefing completion dispatches battle-begin events, including opening dialogue. -7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Clicking an enemy that can be reached by moving first performs the combined move-and-attack action directly, while the map marks those targets with compact move-attack badges plus the chosen advance origin and attack line before the click; the selected attack origin favors defeat chances and low counter risk, and hover badges show possible counter danger even when a miss could cause it. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, add facing-based side/rear damage bonuses, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, class-specific physical attack motion profiles, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, selected/hover unit role, movement, facing, terrain, status, and equipment details, and floating combat text for core action and UI feedback. +7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Clicking an enemy that can be reached by moving first performs the combined move-and-attack action directly, while the map marks those targets with compact move-attack badges plus the chosen advance origin and attack line before the click; the selected attack origin favors defeat chances and low counter risk, and hover badges plus the forecast panel show possible counter danger even when a miss could cause it. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, add facing-based side/rear damage bonuses, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, class-specific physical attack motion profiles, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, selected/hover unit role, movement, facing, terrain, status, and equipment details, and floating combat text for core action and UI feedback. 8. Player ends turn. 9. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock tactics through the same combat and skill resolvers. 10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 4eeeb68..2ceb0cd 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -6152,6 +6152,12 @@ func _update_forecast() -> void: forecast_label.text = "전황: 병장 봉인\n무기로 공격할 수 없습니다." return + if not bool(preview.get("in_range", false)): + var auto_attack_text := _format_auto_attack_forecast_text(selected, target) + if not auto_attack_text.is_empty(): + forecast_label.text = auto_attack_text + return + var range_text := "사거리 안" if preview["in_range"] else "사거리 밖" var defeat_text := " 퇴각" if preview["would_defeat"] else "" var effective_text := _format_effective_forecast_text(preview, "effective_bonus", "effective_target_type") @@ -6182,6 +6188,45 @@ func _update_forecast() -> void: ] +func _format_auto_attack_forecast_text(selected: Dictionary, target: Dictionary) -> String: + if selected.is_empty() or target.is_empty(): + return "" + if basic_attack_targeting or _has_pending_move() or bool(selected.get("moved", false)) or bool(selected.get("acted", false)): + return "" + var origin := _best_auto_attack_origin(selected, target) + if origin == Vector2i(-1, -1): + return "" + var current_cell: Vector2i = selected.get("pos", Vector2i(-1, -1)) + if origin == current_cell: + return "" + var preview := _auto_attack_preview_for_origin(selected, target, origin) + if preview.is_empty(): + return "" + var move_distance := _manhattan(current_cell, origin) + var defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else "" + var directional_text := _format_directional_forecast_text(preview, "directional_bonus", "directional_label") + var counter_text := "" + if bool(preview.get("counter_in_range", false)): + var counter_defeat := " 퇴각" if bool(preview.get("counter_would_defeat", false)) else "" + counter_text = "\n반격 %d, 명중 %d%%.%s" % [ + int(preview.get("counter_damage", 0)), + int(preview.get("counter_hit_chance", 0)), + counter_defeat + ] + return "전황: 행공 진 %d,%d 이동 %d칸\n피해 %d%s, 명중 %d%%, 적 병력 %d/%d.%s%s" % [ + origin.x + 1, + origin.y + 1, + move_distance, + int(preview.get("damage", 0)), + directional_text, + int(preview.get("hit_chance", 0)), + int(preview.get("target_hp_after", 0)), + int(target.get("max_hp", 1)), + defeat_text, + counter_text + ] + + func _format_effective_forecast_text(preview: Dictionary, bonus_key: String, type_key: String) -> String: var bonus := int(preview.get(bonus_key, 0)) if bonus <= 0: diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd index 7ba893f..d8b247a 100644 --- a/tools/smoke_post_move_action_flow.gd +++ b/tools/smoke_post_move_action_flow.gd @@ -631,6 +631,10 @@ func _check_scene_auto_attack_safe_origin_and_counter_preview(failures: Array[St for marker in scene._target_selection_marker_entries(): if bool(marker.get("requires_move", false)): failures.append("formal attack targeting should not show move+attack origin routes: %s" % str(marker)) + scene.hover_cell = Vector2i(5, 3) + scene._update_forecast() + if scene.forecast_label.text.contains("행공"): + failures.append("formal attack targeting forecast should not describe auto move+attack: %s" % scene.forecast_label.text) scene.basic_attack_targeting = false scene._refresh_ranges() @@ -638,6 +642,10 @@ func _check_scene_auto_attack_safe_origin_and_counter_preview(failures: Array[St var safe_badge := scene._target_preview_badge() if str(safe_badge.get("kind", "")) != "advance" or str(safe_badge.get("text", "")).contains("반"): failures.append("safe auto attack hover should not show counter risk: %s" % str(safe_badge)) + scene._update_forecast() + var safe_forecast: String = scene.forecast_label.text + if not safe_forecast.contains("행공") or not safe_forecast.contains("진 4,4") or safe_forecast.contains("사거리 밖"): + failures.append("safe auto attack forecast should describe the chosen advance origin: %s" % safe_forecast) scene._handle_board_click(_screen_for_cell(Vector2i(5, 3))) cao_cao = scene.state.get_unit("cao_cao") @@ -657,15 +665,21 @@ func _check_scene_auto_attack_safe_origin_and_counter_preview(failures: Array[St risky_scene.briefing_panel.visible = false risky_scene.result_panel.visible = false risky_scene._clear_pending_move_state() + for unit in risky_scene.state.units: + if unit.get("team", "") == BattleStateScript.TEAM_ENEMY: + unit["pos"] = Vector2i(risky_scene.state.map_size.x - 2, risky_scene.state.map_size.y - 2) var risky_attacker: Dictionary = risky_scene.state.get_unit("cao_cao") var risky_enemy: Dictionary = risky_scene.state.get_unit("yellow_turban_1") risky_attacker["pos"] = Vector2i(1, 3) + risky_attacker["move"] = 8 risky_attacker["range"] = 1 risky_attacker["min_range"] = 1 risky_attacker["atk"] = 80 risky_attacker["agi"] = 0 risky_attacker["hp"] = 40 risky_attacker["max_hp"] = 40 + risky_attacker["moved"] = false + risky_attacker["acted"] = false risky_enemy["pos"] = Vector2i(5, 3) risky_enemy["range"] = 1 risky_enemy["min_range"] = 1 @@ -682,6 +696,14 @@ func _check_scene_auto_attack_safe_origin_and_counter_preview(failures: Array[St var risky_badge := risky_scene._auto_attack_target_preview_badge_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3)) if not str(risky_badge.get("text", "")).contains("반") or not ["counter", "danger"].has(str(risky_badge.get("kind", ""))): failures.append("risky auto attack badge should show counter risk: %s" % str(risky_badge)) + if not risky_scene.state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for risky auto attack forecast") + else: + risky_scene.hover_cell = Vector2i(5, 3) + risky_scene._update_forecast() + var risky_forecast: String = risky_scene.forecast_label.text + if not risky_forecast.contains("행공") or not risky_forecast.contains("반격"): + failures.append("risky auto attack forecast should show counter risk: %s" % risky_forecast) risky_enemy["controllable"] = false var escort_preview := risky_scene._auto_attack_preview_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3)) if bool(escort_preview.get("counter_in_range", false)):