Improve tactical attack flow

This commit is contained in:
2026-06-19 17:44:58 +09:00
parent 7db09930e7
commit 71e6abb986
5 changed files with 606 additions and 66 deletions

View File

@@ -0,0 +1,174 @@
extends SceneTree
const BattleStateScript := preload("res://scripts/core/battle_state.gd")
const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd")
func _init() -> void:
var failures: Array[String] = []
_check_directional_damage_bonuses(failures)
_check_attack_updates_facing(failures)
_check_move_cancel_restores_facing(failures)
_check_scene_pixel_facing(failures)
if failures.is_empty():
print("directional combat smoke ok")
quit(0)
return
for failure in failures:
push_error(failure)
quit(1)
func _check_directional_damage_bonuses(failures: Array[String]) -> void:
var state = _loaded_state(failures, "directional damage")
if state == null:
return
var attacker: Dictionary = state.get_unit("cao_cao")
var target: Dictionary = state.get_unit("yellow_turban_1")
if attacker.is_empty() or target.is_empty():
failures.append("missing units for directional damage")
return
_configure_duel_units(attacker, target)
target["pos"] = Vector2i(5, 9)
target["facing_x"] = 1
attacker["pos"] = Vector2i(6, 9)
var front_damage: int = state.calculate_damage(attacker, target)
var front_info: Dictionary = state.get_directional_attack_info(attacker, target)
attacker["pos"] = Vector2i(5, 8)
var side_damage: int = state.calculate_damage(attacker, target)
var side_info: Dictionary = state.get_directional_attack_info(attacker, target)
attacker["pos"] = Vector2i(4, 9)
var back_damage: int = state.calculate_damage(attacker, target)
var back_info: Dictionary = state.get_directional_attack_info(attacker, target)
if str(front_info.get("kind", "")) != BattleStateScript.DIRECTIONAL_FRONT_ATTACK:
failures.append("front attack should be classified as front: %s" % str(front_info))
if str(side_info.get("kind", "")) != BattleStateScript.DIRECTIONAL_SIDE_ATTACK:
failures.append("same-file attack should be classified as side: %s" % str(side_info))
if str(back_info.get("kind", "")) != BattleStateScript.DIRECTIONAL_BACK_ATTACK:
failures.append("rear attack should be classified as back: %s" % str(back_info))
if side_damage != front_damage + BattleStateScript.DIRECTIONAL_SIDE_DAMAGE_BONUS:
failures.append("side damage should add side bonus: front %d side %d" % [front_damage, side_damage])
if back_damage != front_damage + BattleStateScript.DIRECTIONAL_BACK_DAMAGE_BONUS:
failures.append("back damage should add back bonus: front %d back %d" % [front_damage, back_damage])
var preview: Dictionary = state.get_damage_preview("cao_cao", "yellow_turban_1")
if str(preview.get("directional_attack", "")) != BattleStateScript.DIRECTIONAL_BACK_ATTACK:
failures.append("damage preview should expose rear attack: %s" % str(preview))
if int(preview.get("directional_bonus", 0)) != BattleStateScript.DIRECTIONAL_BACK_DAMAGE_BONUS:
failures.append("damage preview should expose rear bonus: %s" % str(preview))
func _check_attack_updates_facing(failures: Array[String]) -> void:
var state = _loaded_state(failures, "attack facing")
if state == null:
return
var attacker: Dictionary = state.get_unit("cao_cao")
var target: Dictionary = state.get_unit("yellow_turban_1")
if attacker.is_empty() or target.is_empty():
failures.append("missing units for attack facing")
return
_configure_duel_units(attacker, target)
attacker["pos"] = Vector2i(4, 9)
attacker["facing_x"] = -1
target["pos"] = Vector2i(5, 9)
target["facing_x"] = 1
target["controllable"] = false
var expected_damage: int = state.calculate_damage(attacker, target)
var start_hp: int = int(target.get("hp", 0))
if not state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for attack facing")
return
if not state.try_attack_selected("yellow_turban_1"):
failures.append("rear attack should execute")
return
if int(target.get("hp", 0)) != start_hp - expected_damage:
failures.append("rear attack damage mismatch: expected %d got %d" % [expected_damage, start_hp - int(target.get("hp", 0))])
if state.unit_facing_x(attacker) != 1:
failures.append("attacker should face target after attack")
func _check_move_cancel_restores_facing(failures: Array[String]) -> void:
var state = _loaded_state(failures, "move cancel facing")
if state == null:
return
var unit: Dictionary = state.get_unit("cao_cao")
if unit.is_empty():
failures.append("missing Cao Cao for move cancel facing")
return
unit["facing_x"] = -1
if not state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for move cancel facing")
return
if not state.try_move_selected(Vector2i(2, 3), true):
failures.append("pending move should succeed for facing cancel")
return
if state.unit_facing_x(unit) != 1:
failures.append("pending move to the east should face right")
if not state.cancel_pending_move("cao_cao", Vector2i(1, 3), Vector2i(2, 3)):
failures.append("pending move cancel should succeed for facing restore")
return
if state.unit_facing_x(unit) != -1:
failures.append("canceling a pending move should restore previous facing")
func _check_scene_pixel_facing(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for pixel facing")
scene.free()
return
var player: Dictionary = scene.state.get_unit("cao_cao")
var enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
player["facing_x"] = -1
enemy["facing_x"] = 1
if scene._unit_pixel_facing(player) != -1:
failures.append("pixel facing should use player facing_x")
if scene._unit_pixel_facing(enemy) != 1:
failures.append("pixel facing should use enemy facing_x")
scene.unit_action_motion_by_unit["cao_cao"] = {
"from": Vector2i(4, 3),
"to": Vector2i(3, 3)
}
if scene._unit_pixel_facing(player) != -1:
failures.append("pixel facing should prefer active action motion")
scene.free()
func _configure_duel_units(attacker: Dictionary, target: Dictionary) -> void:
attacker["atk"] = 30
attacker["agi"] = 50
attacker["hp"] = 80
attacker["max_hp"] = 80
attacker["range"] = 1
attacker["min_range"] = 1
attacker["acted"] = false
attacker["moved"] = false
target["def"] = 10
target["atk"] = 1
target["agi"] = 0
target["hp"] = 80
target["max_hp"] = 80
target["range"] = 1
target["min_range"] = 1
target["alive"] = true
target["acted"] = false
target["moved"] = false
func _loaded_state(failures: Array[String], label: String):
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("%s could not load opening battle" % label)
return null
return state

View File

@@ -12,6 +12,7 @@ func _init() -> void:
_check_scene_post_move_text_fit(failures)
_check_scene_post_move_tactic_picker_flow(failures)
_check_scene_post_move_item_picker_flow(failures)
_check_scene_enemy_click_auto_attack(failures)
if failures.is_empty():
print("post move action flow smoke ok")
@@ -278,8 +279,8 @@ func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void:
failures.append("Attack should enter target selection mode")
if scene.post_move_menu != null and scene.post_move_menu.visible:
failures.append("post-move menu should hide while selecting an attack target")
if scene.targeting_hint_panel == null or not scene.targeting_hint_panel.visible:
failures.append("targeting hint panel should be visible during attack targeting")
if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible:
failures.append("attack targeting should not open a separate hint panel")
if not scene.attack_cells.has(Vector2i(3, 3)):
failures.append("attack targeting should highlight the adjacent enemy cell")
var markers := scene._attack_target_marker_entries()
@@ -312,6 +313,56 @@ func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void:
scene.free()
func _check_scene_enemy_click_auto_attack(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 enemy click auto attack")
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()
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["atk"] = 60
cao_cao["agi"] = 999
cao_cao["moved"] = false
cao_cao["acted"] = false
enemy["pos"] = Vector2i(4, 3)
enemy["hp"] = 24
enemy["max_hp"] = 24
enemy["def"] = 1
enemy["alive"] = true
scene.state.rng.seed = 1
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for enemy click auto attack")
scene.free()
return
scene._refresh_ranges()
if scene.attack_cells.has(Vector2i(4, 3)):
failures.append("auto attack setup enemy should start outside current attack range")
scene._handle_board_click(_screen_for_cell(Vector2i(4, 3)))
cao_cao = scene.state.get_unit("cao_cao")
enemy = scene.state.get_unit("yellow_turban_1")
var auto_origin: Vector2i = cao_cao.get("pos", Vector2i.ZERO)
if absi(auto_origin.x - Vector2i(4, 3).x) + absi(auto_origin.y - Vector2i(4, 3).y) != 1:
failures.append("enemy click should auto-move to an attack origin, got %s" % str(auto_origin))
if not bool(cao_cao.get("acted", false)) or not bool(cao_cao.get("moved", false)):
failures.append("enemy click auto attack should spend the unit action")
if int(enemy.get("hp", 0)) >= 24 and bool(enemy.get("alive", true)):
failures.append("enemy click auto attack should damage or defeat the target")
if scene._has_pending_move() or scene.basic_attack_targeting:
failures.append("enemy click auto attack should not leave pending or targeting UI state")
scene.free()
func _check_scene_post_move_tactic_picker_flow(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()

View File

@@ -96,6 +96,7 @@ func _init() -> void:
_check_ancient_ui_theme(failures)
_check_hover_intent_badges(failures)
_check_counter_attack_badge_variants(failures)
_check_hud_command_grid_layout(failures)
_check_action_button_disabled_reasons(failures)
_check_terrain_and_unit_presentation(failures)
_check_objective_and_status_marker_helpers(failures)
@@ -3419,6 +3420,35 @@ func _find_descendant_by_name(root: Node, node_name: String) -> Node:
return null
func _check_hud_command_grid_layout(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
var command_grid := scene.end_turn_button.get_parent() as GridContainer
if command_grid == null:
failures.append("HUD command buttons should live in a fixed grid")
else:
if command_grid.columns != BattleSceneScript.HUD_COMMAND_GRID_COLUMNS:
failures.append("HUD command grid should use %d columns, got %d" % [BattleSceneScript.HUD_COMMAND_GRID_COLUMNS, command_grid.columns])
if command_grid.custom_minimum_size.x < BattleSceneScript.HUD_COMMAND_GRID_SIZE.x or command_grid.custom_minimum_size.y < BattleSceneScript.HUD_COMMAND_GRID_SIZE.y:
failures.append("HUD command grid should reserve stable space: %s" % str(command_grid.custom_minimum_size))
for button in [
scene.wait_button,
scene.tactic_button,
scene.item_button,
scene.equip_button,
scene.threat_button,
scene.end_turn_button
]:
if button == null:
failures.append("HUD command button missing")
continue
if button.custom_minimum_size.x < BattleSceneScript.HUD_COMMAND_BUTTON_SIZE.x or button.custom_minimum_size.y < BattleSceneScript.HUD_COMMAND_BUTTON_SIZE.y:
failures.append("HUD command button should reserve fixed Korean label space: %s" % str(button.custom_minimum_size))
if scene.end_turn_button.text != "차례 종료":
failures.append("end turn command should use a short visible Korean label: %s" % scene.end_turn_button.text)
scene.free()
func _check_action_button_disabled_reasons(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene.wait_button = Button.new()
@@ -3441,7 +3471,7 @@ func _check_action_button_disabled_reasons(failures: Array[String]) -> void:
"remaining_phases": 2
}]
scene._update_tactic_button(cao_cao)
if scene.tactic_button.text != "책략첩|봉인됨" or not scene.tactic_button.tooltip_text.contains("책략"):
if scene.tactic_button.text != "책략" or not scene.tactic_button.tooltip_text.contains("봉인됨") or not scene.tactic_button.tooltip_text.contains("책략"):
failures.append("sealed unit should explain disabled tactic button: %s | %s" % [scene.tactic_button.text, scene.tactic_button.tooltip_text])
cao_cao["status_effects"] = []
@@ -3450,36 +3480,36 @@ func _check_action_button_disabled_reasons(failures: Array[String]) -> void:
scene._update_tactic_button(cao_cao)
scene._update_item_button(cao_cao)
scene._update_equip_button(cao_cao)
if not scene.wait_button.text.contains("봉인됨"):
failures.append("acted unit hold button should show Sealed: %s" % scene.wait_button.text)
if not scene.tactic_button.text.contains("봉인됨"):
failures.append("acted unit stratagem button should show Sealed: %s" % scene.tactic_button.text)
if not scene.item_button.text.contains("봉인됨"):
failures.append("acted unit supply button should show Sealed: %s" % scene.item_button.text)
if not scene.equip_button.text.contains("봉인됨"):
failures.append("acted unit arms button should show Sealed: %s" % scene.equip_button.text)
if scene.wait_button.text != "대기" or not scene.wait_button.tooltip_text.contains("이미 행동"):
failures.append("acted unit hold button should keep short text and explain in tooltip: %s | %s" % [scene.wait_button.text, scene.wait_button.tooltip_text])
if scene.tactic_button.text != "책략" or not scene.tactic_button.tooltip_text.contains("이미 행동"):
failures.append("acted unit stratagem button should keep short text and explain in tooltip: %s | %s" % [scene.tactic_button.text, scene.tactic_button.tooltip_text])
if scene.item_button.text != "도구" or not scene.item_button.tooltip_text.contains("이미 행동"):
failures.append("acted unit supply button should keep short text and explain in tooltip: %s | %s" % [scene.item_button.text, scene.item_button.tooltip_text])
if scene.equip_button.text != "병장" or not scene.equip_button.tooltip_text.contains("이미 행동"):
failures.append("acted unit arms button should keep short text and explain in tooltip: %s | %s" % [scene.equip_button.text, scene.equip_button.tooltip_text])
cao_cao["acted"] = false
cao_cao["moved"] = true
scene._update_equip_button(cao_cao)
if scene.equip_button.text != "병장행군 후" or not scene.equip_button.tooltip_text.contains("행군 전"):
if scene.equip_button.text != "병장" or not scene.equip_button.tooltip_text.contains("행군 후") or not scene.equip_button.tooltip_text.contains("행군 전"):
failures.append("moved unit should explain disabled equip button: %s | %s" % [scene.equip_button.text, scene.equip_button.tooltip_text])
cao_cao["moved"] = false
scene.state.set_inventory_snapshot({})
scene._update_item_button(cao_cao)
if scene.item_button.text != "보급첩|비어 있음" or not scene.item_button.tooltip_text.contains("도구"):
if scene.item_button.text != "도구" or not scene.item_button.tooltip_text.contains("비어 있음") or not scene.item_button.tooltip_text.contains("도구"):
failures.append("empty inventory should explain disabled item button: %s | %s" % [scene.item_button.text, scene.item_button.tooltip_text])
scene.state.current_team = BattleState.TEAM_ENEMY
scene._update_end_turn_button()
if scene.end_turn_button.text != "군령 봉함|적군 차례" or not scene.end_turn_button.tooltip_text.contains("아군 군령"):
if scene.end_turn_button.text != "차례 종료" or not scene.end_turn_button.tooltip_text.contains("적군 차례") or not scene.end_turn_button.tooltip_text.contains("아군 군령"):
failures.append("enemy phase should explain disabled end turn button: %s | %s" % [scene.end_turn_button.text, scene.end_turn_button.tooltip_text])
scene.state.current_team = BattleState.TEAM_PLAYER
scene.battle_started = false
scene._update_threat_button()
if scene.threat_button.text != "진도|전장도 미개봉" or not scene.threat_button.tooltip_text.contains("전장도"):
if scene.threat_button.text != "" or not scene.threat_button.tooltip_text.contains("전장도 미개봉") or not scene.threat_button.tooltip_text.contains("전장도"):
failures.append("inactive battle should explain disabled threat button: %s | %s" % [scene.threat_button.text, scene.threat_button.tooltip_text])
_free_action_button_test_scene(scene)