From 46df282eabe3ded71e69e4e46ff076603e413f4d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 20:49:45 +0900 Subject: [PATCH] Account for counter risk in auto attacks --- docs/ARCHITECTURE.md | 2 +- scripts/scenes/battle_scene.gd | 144 +++++++++++++++++++-------- tools/smoke_post_move_action_flow.gd | 117 ++++++++++++++++++++++ 3 files changed, 220 insertions(+), 43 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 838b323..6a7536e 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 before the click. 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 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. 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 1874935..2053dd6 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -3618,16 +3618,20 @@ func _append_auto_attack_target_marker_entries(result: Array[Dictionary], select var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1)) if not state.is_inside(target_cell): continue - var badge := _auto_attack_target_preview_badge_for_origin(selected, target, attack_origin) - if badge.is_empty(): + var preview := _auto_attack_preview_for_origin(selected, target, attack_origin) + if preview.is_empty(): continue result.append({ "cell": target_cell, "target_id": target_id, "text": "행공", - "kind": str(badge.get("kind", "advance")), + "kind": _auto_attack_preview_badge_kind(preview), "origin": attack_origin, - "requires_move": true + "requires_move": true, + "damage": int(preview.get("damage", 0)), + "counter_damage": int(preview.get("counter_damage", 0)), + "counter_in_range": bool(preview.get("counter_in_range", false)), + "counter_would_defeat": bool(preview.get("counter_would_defeat", false)) }) @@ -3756,6 +3760,8 @@ func _best_auto_attack_origin(selected: Dictionary, target: Dictionary) -> Vecto return Vector2i(-1, -1) var best_cell := Vector2i(-1, -1) var best_score := -999999 + var best_preview := {} + var best_move_distance := 999999 for cell in state.get_movement_range(unit_id): if not state.is_inside(cell): continue @@ -3764,19 +3770,50 @@ func _best_auto_attack_origin(selected: Dictionary, target: Dictionary) -> Vecto var blocker := state.get_unit_at(cell) if not blocker.is_empty() and str(blocker.get("id", "")) != unit_id: continue - if not _can_attack_target_from(selected, cell, target_cell): + var preview := _auto_attack_preview_for_origin(selected, target, cell) + if preview.is_empty(): continue - var candidate_attacker := selected.duplicate(true) - candidate_attacker["pos"] = cell - var damage: int = state.calculate_damage(candidate_attacker, target) var move_distance: int = _manhattan(current_cell, cell) - var score: int = damage * 20 - move_distance - if score > best_score: + var score: int = _auto_attack_origin_score(preview, move_distance) + if best_cell == Vector2i(-1, -1) or _auto_attack_origin_is_better(score, preview, move_distance, best_score, best_preview, best_move_distance): best_score = score + best_preview = preview + best_move_distance = move_distance best_cell = cell return best_cell +func _auto_attack_origin_score(preview: Dictionary, move_distance: int) -> int: + var score := int(preview.get("damage", 0)) * 100 + int(preview.get("hit_chance", 0)) - move_distance + if bool(preview.get("would_defeat", false)): + score += 100000 + if bool(preview.get("counter_in_range", false)): + score -= 1500 + score -= int(preview.get("counter_damage", 0)) * 80 + score -= int(preview.get("counter_hit_chance", 0)) * 2 + if bool(preview.get("counter_would_defeat", false)): + score -= 100000 + return score + + +func _auto_attack_origin_is_better(score: int, preview: Dictionary, move_distance: int, best_score: int, best_preview: Dictionary, best_move_distance: int) -> bool: + if score != best_score: + return score > best_score + var counter_defeat := bool(preview.get("counter_would_defeat", false)) + var best_counter_defeat := bool(best_preview.get("counter_would_defeat", false)) + if counter_defeat != best_counter_defeat: + return not counter_defeat + var counter_damage := int(preview.get("counter_damage", 0)) + var best_counter_damage := int(best_preview.get("counter_damage", 0)) + if counter_damage != best_counter_damage: + return counter_damage < best_counter_damage + var damage := int(preview.get("damage", 0)) + var best_damage := int(best_preview.get("damage", 0)) + if damage != best_damage: + return damage > best_damage + return move_distance < best_move_distance + + func _can_attack_target_from(unit: Dictionary, origin: Vector2i, target_cell: Vector2i) -> bool: var attack_range: int = maxi(1, int(unit.get("range", 1))) var min_range: int = clampi(int(unit.get("min_range", mini(1, attack_range))), 1, attack_range) @@ -4825,16 +4862,56 @@ func _auto_attack_target_preview_badge(selected: Dictionary, target: Dictionary) func _auto_attack_target_preview_badge_for_origin(selected: Dictionary, target: Dictionary, attack_origin: Vector2i) -> Dictionary: + var preview := _auto_attack_preview_for_origin(selected, target, attack_origin) + if preview.is_empty(): + return {} + var target_cell: Vector2i = preview.get("target_cell", Vector2i(-1, -1)) + var damage := int(preview.get("damage", 0)) + var hit_chance := int(preview.get("hit_chance", 0)) + var result_text := "행공 퇴각" if bool(preview.get("would_defeat", false)) else "행공 -%d" % damage + var directional_text := _format_directional_badge_suffix(preview) + var counter_text := _format_auto_attack_counter_badge_suffix(preview) + return _make_target_preview_badge_at("%s%s %d%%%s" % [result_text, directional_text, hit_chance, counter_text], _auto_attack_preview_badge_kind(preview), target_cell) + + +func _auto_attack_preview_for_origin(selected: Dictionary, target: Dictionary, attack_origin: Vector2i) -> Dictionary: var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1)) - if not state.is_inside(attack_origin) or not state.is_inside(target_cell): + if selected.is_empty() or target.is_empty() or not state.is_inside(attack_origin) or not state.is_inside(target_cell): + return {} + if not _can_attack_target_from(selected, attack_origin, target_cell): return {} var candidate_attacker := _auto_attack_candidate_after_move(selected, target_cell, attack_origin) var damage := state.calculate_damage(candidate_attacker, target) var hit_chance := state.calculate_hit_chance(candidate_attacker, target) - var result_text := "행공 퇴각" if int(target.get("hp", 0)) - damage <= 0 else "행공 -%d" % damage - var directional_text := _format_directional_badge_suffix_for_units(candidate_attacker, target) - var counter_text := _format_auto_attack_counter_badge_suffix(candidate_attacker, target, damage) - return _make_target_preview_badge_at("%s%s %d%%%s" % [result_text, directional_text, hit_chance, counter_text], _auto_attack_preview_badge_kind(candidate_attacker, target, damage), target_cell) + var target_hp_after := maxi(0, int(target.get("hp", 0)) - damage) + var directional_info := state.get_directional_attack_info(candidate_attacker, target) + var counter_in_range := false + var counter_damage := 0 + var counter_hit_chance := 0 + var counter_would_defeat := false + if bool(target.get("controllable", true)) and (target_hp_after > 0 or hit_chance < 100): + counter_in_range = _can_attack_target_from(target, target_cell, attack_origin) + if counter_in_range: + var counter_attacker := target.duplicate(true) + _face_unit_snapshot_toward_cell(counter_attacker, attack_origin) + counter_damage = state.calculate_damage(counter_attacker, candidate_attacker) + counter_hit_chance = state.calculate_hit_chance(counter_attacker, candidate_attacker) + counter_would_defeat = int(candidate_attacker.get("hp", 0)) - counter_damage <= 0 + return { + "origin": attack_origin, + "target_cell": target_cell, + "damage": damage, + "hit_chance": hit_chance, + "target_hp_after": target_hp_after, + "would_defeat": target_hp_after <= 0, + "directional_attack": str(directional_info.get("kind", BattleState.DIRECTIONAL_FRONT_ATTACK)), + "directional_bonus": int(directional_info.get("bonus", 0)), + "directional_label": str(directional_info.get("label", "")), + "counter_in_range": counter_in_range, + "counter_damage": counter_damage, + "counter_hit_chance": counter_hit_chance, + "counter_would_defeat": counter_would_defeat + } func _auto_attack_candidate_after_move(selected: Dictionary, target_cell: Vector2i, attack_origin: Vector2i) -> Dictionary: @@ -4853,39 +4930,22 @@ func _face_unit_snapshot_toward_cell(unit: Dictionary, target_cell: Vector2i) -> unit["facing_x"] = -1 if target_cell.x < from_cell.x else 1 -func _format_directional_badge_suffix_for_units(attacker: Dictionary, target: Dictionary) -> String: - var directional_info := state.get_directional_attack_info(attacker, target) - return _format_directional_badge_suffix({ - "directional_attack": str(directional_info.get("kind", BattleState.DIRECTIONAL_FRONT_ATTACK)), - "directional_bonus": int(directional_info.get("bonus", 0)), - "directional_label": str(directional_info.get("label", "")) - }) - - -func _format_auto_attack_counter_badge_suffix(candidate_attacker: Dictionary, target: Dictionary, damage: int) -> String: - if int(target.get("hp", 0)) - damage <= 0: +func _format_auto_attack_counter_badge_suffix(preview: Dictionary) -> String: + if not bool(preview.get("counter_in_range", false)): return "" - var origin: Vector2i = candidate_attacker.get("pos", Vector2i(-1, -1)) - var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1)) - if not _can_attack_target_from(target, target_cell, origin): - return "" - var counter_damage := state.calculate_damage(target, candidate_attacker) - if int(candidate_attacker.get("hp", 0)) - counter_damage <= 0: + if bool(preview.get("counter_would_defeat", false)): return " 반격!" - return " 반%d" % counter_damage + return " 반%d" % int(preview.get("counter_damage", 0)) -func _auto_attack_preview_badge_kind(candidate_attacker: Dictionary, target: Dictionary, damage: int) -> String: - if int(target.get("hp", 0)) - damage <= 0: +func _auto_attack_preview_badge_kind(preview: Dictionary) -> String: + if bool(preview.get("would_defeat", false)) and not bool(preview.get("counter_in_range", false)): return "advance" - var origin: Vector2i = candidate_attacker.get("pos", Vector2i(-1, -1)) - var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1)) - if not _can_attack_target_from(target, target_cell, origin): - return "advance" - var counter_damage := state.calculate_damage(target, candidate_attacker) - if int(candidate_attacker.get("hp", 0)) - counter_damage <= 0: + if bool(preview.get("counter_would_defeat", false)): return "danger" - return "counter" + if bool(preview.get("counter_in_range", false)): + return "counter" + return "advance" func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary: diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd index 325bd50..5f1b233 100644 --- a/tools/smoke_post_move_action_flow.gd +++ b/tools/smoke_post_move_action_flow.gd @@ -15,6 +15,7 @@ func _init() -> void: _check_scene_post_move_blocked_command_labels(failures) _check_scene_post_move_tactic_picker_flow(failures) _check_scene_post_move_item_picker_flow(failures) + _check_scene_auto_attack_safe_origin_and_counter_preview(failures) _check_scene_enemy_click_auto_attack(failures) if failures.is_empty(): @@ -552,6 +553,122 @@ func _check_scene_enemy_click_auto_attack(failures: Array[String]) -> void: scene.free() +func _check_scene_auto_attack_safe_origin_and_counter_preview(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._create_hud() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for safe auto attack origin") + scene.free() + return + scene.battle_started = true + scene.campaign_complete_screen = false + scene.briefing_panel.visible = false + scene.result_panel.visible = false + scene._clear_pending_move_state() + + for unit in scene.state.units: + if unit.get("team", "") == BattleStateScript.TEAM_ENEMY: + unit["pos"] = Vector2i(scene.state.map_size.x - 2, scene.state.map_size.y - 2) + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") + cao_cao["pos"] = Vector2i(1, 3) + cao_cao["move"] = 4 + cao_cao["range"] = 2 + cao_cao["min_range"] = 1 + cao_cao["atk"] = 30 + cao_cao["agi"] = 999 + cao_cao["hp"] = 80 + cao_cao["max_hp"] = 80 + cao_cao["moved"] = false + cao_cao["acted"] = false + enemy["pos"] = Vector2i(5, 3) + enemy["range"] = 1 + enemy["min_range"] = 1 + enemy["atk"] = 70 + enemy["agi"] = 0 + enemy["hp"] = 80 + enemy["max_hp"] = 80 + enemy["def"] = 1 + enemy["alive"] = true + enemy["controllable"] = true + scene.state.rng.seed = 1 + + if not scene.state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for safe auto attack origin") + scene.free() + return + scene._refresh_ranges() + var auto_markers := scene._target_selection_marker_entries() + var safe_marker := {} + for marker in auto_markers: + if marker.get("target_id", "") == "yellow_turban_1": + safe_marker = marker + break + if safe_marker.is_empty(): + failures.append("safe auto attack setup should mark the reachable enemy: %s" % str(auto_markers)) + else: + if safe_marker.get("origin", Vector2i.ZERO) != Vector2i(3, 3): + failures.append("auto attack should prefer the non-counter origin: %s" % str(safe_marker)) + if bool(safe_marker.get("counter_in_range", true)): + failures.append("safe auto attack marker should expose no counter risk: %s" % str(safe_marker)) + if str(safe_marker.get("kind", "")) != "advance": + failures.append("safe auto attack marker should use advance color: %s" % str(safe_marker)) + + scene.hover_cell = Vector2i(5, 3) + 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._handle_board_click(_screen_for_cell(Vector2i(5, 3))) + cao_cao = scene.state.get_unit("cao_cao") + if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(3, 3): + failures.append("auto attack click should use the same safe origin as marker and hover: %s" % str(cao_cao.get("pos", Vector2i.ZERO))) + + scene.free() + + var risky_scene = BattleSceneScript.new() + risky_scene._create_hud() + if not risky_scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for risky auto attack preview") + risky_scene.free() + return + risky_scene.battle_started = true + risky_scene.campaign_complete_screen = false + risky_scene.briefing_panel.visible = false + risky_scene.result_panel.visible = false + risky_scene._clear_pending_move_state() + 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["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_enemy["pos"] = Vector2i(5, 3) + risky_enemy["range"] = 1 + risky_enemy["min_range"] = 1 + risky_enemy["atk"] = 70 + risky_enemy["agi"] = 50 + risky_enemy["hp"] = 20 + risky_enemy["max_hp"] = 20 + risky_enemy["def"] = 1 + risky_enemy["alive"] = true + risky_enemy["controllable"] = true + var risky_preview := risky_scene._auto_attack_preview_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3)) + if risky_preview.is_empty() or not bool(risky_preview.get("would_defeat", false)) or not bool(risky_preview.get("counter_in_range", false)): + failures.append("risky auto attack preview should expose miss-counter risk on a potential KO: %s" % str(risky_preview)) + 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)) + 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)): + failures.append("non-controllable targets should not show counter risk: %s" % str(escort_preview)) + risky_scene.free() + + func _check_scene_post_move_tactic_picker_flow(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud()