Account for counter risk in auto attacks

This commit is contained in:
2026-06-19 20:49:45 +09:00
parent 184a542b04
commit 46df282eab
3 changed files with 220 additions and 43 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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()