Add local post-move tactic and item pickers

This commit is contained in:
2026-06-18 22:19:58 +09:00
parent e5d4b4f010
commit 493496fc6f
2 changed files with 555 additions and 10 deletions

View File

@@ -61,6 +61,8 @@ const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104)
const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100)
const POST_MOVE_MENU_SIZE := Vector2(228, 120)
const POST_MOVE_MENU_OFFSET := Vector2(20, -34)
const POST_MOVE_PICKER_PANEL_SIZE := Vector2(320, 210)
const POST_MOVE_PICKER_PANEL_OFFSET := Vector2(20, 92)
const TARGETING_HINT_PANEL_SIZE := Vector2(240, 94)
const TARGETING_HINT_PANEL_OFFSET := Vector2(18, 88)
@@ -173,6 +175,13 @@ var post_move_tactic_button: Button
var post_move_item_button: Button
var post_move_wait_button: Button
var post_move_cancel_button: Button
var post_move_picker_panel: PanelContainer
var post_move_picker_title_label: Label
var post_move_picker_detail_label: Label
var post_move_picker_list: VBoxContainer
var post_move_picker_back_button: Button
var post_move_picker_cancel_move_button: Button
var post_move_picker_mode := ""
var targeting_hint_panel: PanelContainer
var targeting_hint_title_label: Label
var targeting_hint_detail_label: Label
@@ -504,6 +513,53 @@ func _create_hud() -> void:
post_move_cancel_button.pressed.connect(_on_post_move_cancel_pressed)
post_move_column.add_child(post_move_cancel_button)
post_move_picker_panel = PanelContainer.new()
post_move_picker_panel.visible = false
post_move_picker_panel.mouse_filter = Control.MOUSE_FILTER_STOP
post_move_picker_panel.custom_minimum_size = POST_MOVE_PICKER_PANEL_SIZE
post_move_picker_panel.size = POST_MOVE_PICKER_PANEL_SIZE
root.add_child(post_move_picker_panel)
var picker_column := VBoxContainer.new()
picker_column.add_theme_constant_override("separation", 5)
post_move_picker_panel.add_child(picker_column)
post_move_picker_title_label = Label.new()
post_move_picker_title_label.text = "Choose"
post_move_picker_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
picker_column.add_child(post_move_picker_title_label)
post_move_picker_detail_label = Label.new()
post_move_picker_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
post_move_picker_detail_label.custom_minimum_size = Vector2(296, 32)
picker_column.add_child(post_move_picker_detail_label)
var picker_scroll := ScrollContainer.new()
picker_scroll.custom_minimum_size = Vector2(296, 98)
picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
picker_column.add_child(picker_scroll)
post_move_picker_list = VBoxContainer.new()
post_move_picker_list.add_theme_constant_override("separation", 4)
post_move_picker_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
picker_scroll.add_child(post_move_picker_list)
var picker_button_row := HBoxContainer.new()
picker_button_row.add_theme_constant_override("separation", 6)
picker_column.add_child(picker_button_row)
post_move_picker_back_button = Button.new()
post_move_picker_back_button.text = "Back"
post_move_picker_back_button.tooltip_text = "Return to the action menu."
post_move_picker_back_button.pressed.connect(_on_post_move_picker_back_pressed)
picker_button_row.add_child(post_move_picker_back_button)
post_move_picker_cancel_move_button = Button.new()
post_move_picker_cancel_move_button.text = "Cancel Move"
post_move_picker_cancel_move_button.tooltip_text = "Return to the starting cell."
post_move_picker_cancel_move_button.pressed.connect(_on_post_move_picker_cancel_move_pressed)
picker_button_row.add_child(post_move_picker_cancel_move_button)
targeting_hint_panel = PanelContainer.new()
targeting_hint_panel.visible = false
targeting_hint_panel.mouse_filter = Control.MOUSE_FILTER_STOP
@@ -1146,6 +1202,7 @@ func _clear_pending_move_state(hide_menu := true) -> void:
basic_attack_targeting = false
if hide_menu:
_hide_post_move_menu()
_hide_post_move_picker()
_hide_targeting_hint_panel()
@@ -1171,6 +1228,7 @@ func _cancel_pending_move() -> bool:
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_menu()
_hide_post_move_picker()
if state.cancel_pending_move(unit_id, from_cell, to_cell):
_clear_pending_move_state()
_play_ui_cancel()
@@ -1184,6 +1242,9 @@ func _cancel_pending_move() -> bool:
func _handle_cancel_input() -> bool:
if _has_pending_move():
return _cancel_pending_move()
if post_move_picker_panel != null and post_move_picker_panel.visible:
_on_post_move_picker_back_pressed()
return true
if tactic_menu != null and tactic_menu.visible:
_play_ui_cancel()
_hide_tactic_menu()
@@ -1239,10 +1300,20 @@ func _handle_key(event: InputEventKey) -> void:
_hide_formation_menu()
elif save_menu != null and save_menu.visible:
_hide_save_menu()
elif post_move_picker_panel != null and post_move_picker_panel.visible:
_on_post_move_picker_back_pressed()
elif tactic_menu != null and tactic_menu.visible:
_hide_tactic_menu()
if _has_pending_move():
_show_post_move_menu()
_update_hud()
queue_redraw()
elif item_menu != null and item_menu.visible:
_hide_item_menu()
if _has_pending_move():
_show_post_move_menu()
_update_hud()
queue_redraw()
elif equip_menu != null and equip_menu.visible:
_hide_equip_menu()
else:
@@ -2397,6 +2468,7 @@ func _update_hud() -> void:
restart_button.disabled = campaign_complete_screen
_update_threat_button()
_update_post_move_menu()
_update_post_move_picker()
_update_targeting_hint_panel()
@@ -2488,11 +2560,45 @@ func _unit_action_status_text(unit: Dictionary) -> String:
func _unit_has_usable_item_target(unit_id: String) -> bool:
for item_id in state.get_usable_item_ids():
if not state.get_item_target_cells(unit_id, str(item_id)).is_empty():
if _item_has_valid_target(unit_id, str(item_id)):
return true
return false
func _unit_has_usable_skill_target(unit_id: String) -> bool:
for skill_id in state.get_usable_skill_ids(unit_id):
if _skill_has_valid_target(unit_id, str(skill_id)):
return true
return false
func _skill_has_valid_target(unit_id: String, skill_id: String) -> bool:
for cell in state.get_skill_cells(unit_id, skill_id):
var preview := state.get_skill_preview(unit_id, skill_id, cell)
if bool(preview.get("valid_target", false)):
return true
return false
func _item_has_valid_target(unit_id: String, item_id: String) -> bool:
for cell in state.get_item_target_cells(unit_id, item_id):
var preview := state.get_item_preview(unit_id, item_id, cell)
if _item_preview_can_apply(preview):
return true
return false
func _item_preview_can_apply(preview: Dictionary) -> bool:
if preview.is_empty() or not bool(preview.get("in_range", false)) or not bool(preview.get("valid_target", false)):
return false
if int(preview.get("count", 0)) <= 0:
return false
if int(preview.get("heal", 0)) > 0 or int(preview.get("mp_heal", 0)) > 0:
return true
var cured = preview.get("cure_statuses", [])
return typeof(cured) == TYPE_ARRAY and not cured.is_empty()
func _unit_role_text(unit: Dictionary) -> String:
var class_id := str(unit.get("class_id", ""))
if class_id.contains("cavalry"):
@@ -3373,6 +3479,7 @@ func _on_wait_pressed() -> void:
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
if _commit_pending_move_before_action() and state.try_wait_selected():
_play_ui_confirm()
@@ -3387,6 +3494,7 @@ func _on_end_turn_pressed() -> void:
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
if not (_commit_pending_move_before_action() and state.try_wait_selected()):
return
selected_skill_id = ""
@@ -3395,6 +3503,7 @@ func _on_end_turn_pressed() -> void:
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
state.end_player_turn()
@@ -3407,6 +3516,7 @@ func _on_tactic_pressed() -> void:
selected_skill_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_post_move_picker()
_refresh_ranges()
_update_hud()
queue_redraw()
@@ -3420,6 +3530,7 @@ func _on_tactic_pressed() -> void:
selected_item_id = ""
basic_attack_targeting = false
_hide_post_move_menu()
_hide_post_move_picker()
_hide_item_menu()
_hide_equip_menu()
_show_tactic_menu(selected)
@@ -3441,6 +3552,7 @@ func _on_item_pressed() -> void:
selected_skill_id = ""
basic_attack_targeting = false
_hide_post_move_menu()
_hide_post_move_picker()
_hide_tactic_menu()
_hide_equip_menu()
_show_item_menu(selected)
@@ -3465,6 +3577,7 @@ func _on_equip_pressed() -> void:
_hide_tactic_menu()
_hide_item_menu()
_hide_post_move_menu()
_hide_post_move_picker()
_show_equip_menu(selected)
_refresh_ranges()
_update_hud()
@@ -5250,10 +5363,9 @@ func _update_post_move_menu() -> void:
post_move_attack_button.disabled = attack_blocked
post_move_attack_button.tooltip_text = "No enemy is in range after this move." if attack_blocked else "Choose an enemy target from this unit's current position."
if post_move_tactic_button != null:
var skill_ids := state.get_skill_ids(pending_move_unit_id)
var skill_blocked := skill_ids.is_empty() or state.is_unit_skill_sealed(pending_move_unit_id) or state.get_first_usable_skill_id(pending_move_unit_id).is_empty()
var skill_blocked := not _unit_has_usable_skill_target(pending_move_unit_id)
post_move_tactic_button.disabled = skill_blocked
post_move_tactic_button.tooltip_text = "No usable tactic is available after this move." if skill_blocked else "Open tactics after this move."
post_move_tactic_button.tooltip_text = "No tactic has a valid target after this move." if skill_blocked else "Choose a tactic from this unit's current position."
if post_move_item_button != null:
var item_blocked := not _unit_has_usable_item_target(pending_move_unit_id)
post_move_item_button.disabled = item_blocked
@@ -5281,6 +5393,236 @@ func _position_post_move_menu() -> void:
post_move_menu.position = next_position
func _show_post_move_picker(mode: String) -> void:
if post_move_picker_panel == null or not _has_pending_move():
return
var selected := state.get_selected_unit()
if selected.is_empty() or str(selected.get("id", "")) != pending_move_unit_id:
return
post_move_picker_mode = mode
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_post_move_menu()
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_rebuild_post_move_picker(selected)
post_move_picker_panel.visible = true
post_move_picker_panel.move_to_front()
func _hide_post_move_picker() -> void:
if post_move_picker_panel == null:
return
post_move_picker_panel.visible = false
post_move_picker_mode = ""
func _update_post_move_picker() -> void:
if post_move_picker_panel == null or not post_move_picker_panel.visible:
return
if not _has_pending_move():
_hide_post_move_picker()
return
var selected := state.get_selected_unit()
if selected.is_empty() or str(selected.get("id", "")) != pending_move_unit_id or bool(selected.get("acted", false)):
_hide_post_move_picker()
return
_rebuild_post_move_picker(selected)
func _rebuild_post_move_picker(selected: Dictionary) -> void:
if post_move_picker_panel == null or post_move_picker_list == null:
return
_clear_post_move_picker_list()
_position_post_move_picker()
if post_move_picker_title_label != null:
post_move_picker_title_label.text = _post_move_picker_title_text(selected)
if post_move_picker_detail_label != null:
post_move_picker_detail_label.text = _post_move_picker_detail_text()
if post_move_picker_mode == "tactic":
_rebuild_post_move_tactic_picker(selected)
elif post_move_picker_mode == "item":
_rebuild_post_move_item_picker(selected)
else:
var empty_label := Label.new()
empty_label.text = "No choices"
post_move_picker_list.add_child(empty_label)
func _clear_post_move_picker_list() -> void:
if post_move_picker_list == null:
return
for child in post_move_picker_list.get_children():
post_move_picker_list.remove_child(child)
child.queue_free()
func _position_post_move_picker() -> void:
if post_move_picker_panel == null:
return
var cell_rect := _rect_for_cell(pending_move_to_cell)
var next_position := cell_rect.position + POST_MOVE_PICKER_PANEL_OFFSET
var board_size := Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE
var board_min := BOARD_OFFSET + Vector2(4.0, 4.0)
var board_max := BOARD_OFFSET + board_size - POST_MOVE_PICKER_PANEL_SIZE - Vector2(4.0, 4.0)
board_max.x = maxf(board_min.x, board_max.x)
board_max.y = maxf(board_min.y, board_max.y)
next_position.x = clampf(next_position.x, board_min.x, board_max.x)
next_position.y = clampf(next_position.y, board_min.y, board_max.y)
post_move_picker_panel.position = next_position
func _post_move_picker_title_text(selected: Dictionary) -> String:
var unit_name := str(selected.get("name", "Unit"))
if post_move_picker_mode == "tactic":
return "%s: choose tactic" % unit_name
if post_move_picker_mode == "item":
return "%s: choose item" % unit_name
return "%s: choose" % unit_name
func _post_move_picker_detail_text() -> String:
if post_move_picker_mode == "tactic":
return "Pick a tactic, then click a highlighted target."
if post_move_picker_mode == "item":
return "Pick an item, then click a highlighted ally."
return "Choose an option."
func _rebuild_post_move_tactic_picker(selected: Dictionary) -> void:
var skill_ids := state.get_skill_ids(selected["id"])
if skill_ids.is_empty():
var empty_label := Label.new()
empty_label.text = "No tactics"
post_move_picker_list.add_child(empty_label)
return
for skill_id_value in skill_ids:
var skill_id := str(skill_id_value)
var skill := state.get_skill_def(skill_id)
var skill_button := Button.new()
skill_button.text = _format_post_move_tactic_button_text(skill_id, skill, selected)
var disabled_reason := _post_move_tactic_disabled_reason(selected, skill_id, skill)
skill_button.disabled = not disabled_reason.is_empty()
skill_button.tooltip_text = disabled_reason if not disabled_reason.is_empty() else "Choose this tactic."
skill_button.pressed.connect(_on_post_move_tactic_option_pressed.bind(skill_id))
post_move_picker_list.add_child(skill_button)
func _rebuild_post_move_item_picker(selected: Dictionary) -> void:
var item_ids := state.get_usable_item_ids()
if item_ids.is_empty():
var empty_label := Label.new()
empty_label.text = "No items"
post_move_picker_list.add_child(empty_label)
return
var inventory := state.get_inventory_snapshot()
for item_id_value in item_ids:
var item_id := str(item_id_value)
var item := state.get_item_def(item_id)
var count := int(inventory.get(item_id, 0))
var item_button := Button.new()
item_button.text = _format_post_move_item_button_text(item_id, item, count)
_apply_item_button_icon(item_button, item)
var disabled_reason := _post_move_item_disabled_reason(selected, item_id, count)
item_button.disabled = not disabled_reason.is_empty()
item_button.tooltip_text = disabled_reason if not disabled_reason.is_empty() else "Choose this item."
item_button.pressed.connect(_on_post_move_item_option_pressed.bind(item_id))
post_move_picker_list.add_child(item_button)
func _format_post_move_tactic_button_text(skill_id: String, skill: Dictionary, selected: Dictionary) -> String:
var marker := "> " if selected_skill_id == skill_id else ""
var skill_name := str(skill.get("name", skill_id))
var mp_cost := int(skill.get("mp_cost", 0))
var kind := str(skill.get("kind", "skill")).capitalize()
var range_text := _format_tactic_range_text(skill)
var disabled_text := ""
if state.is_unit_skill_sealed(selected["id"]):
disabled_text = " - Sealed"
elif int(selected.get("mp", 0)) < mp_cost:
disabled_text = " - MP"
elif not _skill_has_valid_target(str(selected.get("id", "")), skill_id):
disabled_text = " - No target"
return "%s%s %dMP %s R%s%s" % [marker, skill_name, mp_cost, kind, range_text, disabled_text]
func _format_post_move_item_button_text(item_id: String, item: Dictionary, count: int) -> String:
var marker := "> " if selected_item_id == item_id else ""
var item_name := str(item.get("name", item_id))
return "%s%s x%d %s" % [marker, item_name, count, _format_post_move_item_effect_text(item)]
func _format_post_move_item_effect_text(item: Dictionary) -> String:
var has_cure := false
for effect in item.get("effects", []):
if typeof(effect) != TYPE_DICTIONARY:
continue
var effect_type := str(effect.get("type", ""))
if effect_type == "heal_hp":
return "HP +%d" % int(effect.get("amount", 0))
if effect_type == "heal_mp":
return "MP +%d" % int(effect.get("amount", 0))
if effect_type == "cure_status" or effect_type == "cleanse_debuffs":
has_cure = true
if has_cure:
return "Cure"
return str(item.get("kind", "item")).capitalize()
func _post_move_tactic_disabled_reason(selected: Dictionary, skill_id: String, skill: Dictionary) -> String:
if skill.is_empty():
return "Unknown tactic."
if state.is_unit_skill_sealed(selected["id"]):
return "This unit is sealed."
if bool(selected.get("acted", false)):
return "This unit has already acted."
if int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0)):
return "Not enough MP."
if not _skill_has_valid_target(str(selected.get("id", "")), skill_id):
return "No valid target from this tile."
return ""
func _post_move_item_disabled_reason(selected: Dictionary, item_id: String, count: int) -> String:
if count <= 0:
return "No stock remains."
if not _item_has_valid_target(str(selected.get("id", "")), item_id):
return "No valid target from this tile."
return ""
func _on_post_move_tactic_option_pressed(skill_id: String) -> void:
_hide_post_move_picker()
_on_tactic_skill_pressed(skill_id)
func _on_post_move_item_option_pressed(item_id: String) -> void:
_hide_post_move_picker()
_on_item_selected_pressed(item_id)
func _on_post_move_picker_back_pressed() -> void:
_play_ui_cancel()
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_post_move_picker()
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
if _has_pending_move():
_show_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_post_move_picker_cancel_move_pressed() -> void:
_cancel_pending_move()
func _on_post_move_attack_pressed() -> void:
if not _has_pending_move():
return
@@ -5294,6 +5636,7 @@ func _on_post_move_attack_pressed() -> void:
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
_hide_post_move_menu()
_refresh_ranges()
_update_hud()
@@ -5303,17 +5646,27 @@ func _on_post_move_attack_pressed() -> void:
func _on_post_move_tactic_pressed() -> void:
if not _has_pending_move():
return
_hide_post_move_menu()
basic_attack_targeting = false
_on_tactic_pressed()
if not _unit_has_usable_skill_target(pending_move_unit_id):
_play_ui_cancel()
return
_play_ui_click()
_show_post_move_picker("tactic")
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_post_move_item_pressed() -> void:
if not _has_pending_move():
return
_hide_post_move_menu()
basic_attack_targeting = false
_on_item_pressed()
if not _unit_has_usable_item_target(pending_move_unit_id):
_play_ui_cancel()
return
_play_ui_click()
_show_post_move_picker("item")
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_post_move_wait_pressed() -> void:
@@ -5323,6 +5676,7 @@ func _on_post_move_wait_pressed() -> void:
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
if _commit_pending_move_before_action() and state.try_wait_selected():
_play_ui_confirm()

View File

@@ -8,6 +8,8 @@ func _init() -> void:
var failures: Array[String] = []
_check_deferred_move_events(failures)
_check_scene_post_move_menu_flow(failures)
_check_scene_post_move_tactic_picker_flow(failures)
_check_scene_post_move_item_picker_flow(failures)
if failures.is_empty():
print("post move action flow smoke ok")
@@ -209,5 +211,194 @@ func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void:
scene.free()
func _check_scene_post_move_tactic_picker_flow(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 tactic picker flow")
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 enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
enemy["pos"] = Vector2i(3, 3)
enemy["hp"] = 28
enemy["def"] = 1
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["int"] = 80
cao_cao["mp"] = 10
scene.state.battle_events.append({
"id": "pending_tactic_gold",
"once": true,
"when": {
"type": "unit_reaches_tile",
"unit_ids": ["cao_cao"],
"pos": [2, 3]
},
"actions": [{"type": "grant_gold", "amount": 44}]
})
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for tactic picker flow")
scene.free()
return
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(2, 3)))
if scene.post_move_tactic_button == null or scene.post_move_tactic_button.disabled:
failures.append("post-move Tactic should be enabled when Spark has a valid target")
scene._on_post_move_tactic_pressed()
if scene.post_move_picker_panel == null or not scene.post_move_picker_panel.visible or scene.post_move_picker_mode != "tactic":
failures.append("post-move Tactic should open the local tactic picker")
if scene.post_move_menu != null and scene.post_move_menu.visible:
failures.append("post-move action menu should hide while the tactic picker is open")
if scene.tactic_menu != null and scene.tactic_menu.visible:
failures.append("post-move local tactic picker should not use the side tactic menu")
var escape := InputEventKey.new()
escape.keycode = KEY_ESCAPE
scene._handle_key(escape)
cao_cao = scene.state.get_unit("cao_cao")
if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible:
failures.append("Escape should close the local tactic picker")
if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("Escape from the tactic picker should return to the post-move action menu")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("Escape from the tactic picker should preserve the pending move")
scene._on_post_move_tactic_pressed()
scene._on_post_move_tactic_option_pressed("spark")
if scene.selected_skill_id != "spark":
failures.append("choosing Spark should enter tactic targeting mode")
if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible:
failures.append("tactic picker should hide after choosing a tactic")
if scene.targeting_hint_panel == null or not scene.targeting_hint_panel.visible:
failures.append("targeting hint panel should be visible during tactic targeting")
if not scene.skill_cells.has(Vector2i(3, 3)):
failures.append("Spark targeting should highlight the adjacent enemy cell")
scene._on_targeting_back_pressed()
cao_cao = scene.state.get_unit("cao_cao")
if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("tactic Back should return to the post-move action menu")
if scene.selected_skill_id != "":
failures.append("tactic Back should clear the selected tactic")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("tactic Back should preserve the pending moved unit without acting")
scene._on_post_move_tactic_pressed()
scene._on_post_move_tactic_option_pressed("spark")
var enemy_hp_before := int(enemy.get("hp", 0))
scene._handle_board_click(_screen_for_cell(Vector2i(3, 3)))
cao_cao = scene.state.get_unit("cao_cao")
enemy = scene.state.get_unit("yellow_turban_1")
if scene._has_pending_move() or scene.selected_skill_id != "":
failures.append("tactic target click should clear pending move and selected tactic")
if not scene.state.get_selected_unit().is_empty():
failures.append("tactic target click should clear selected unit after acting")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)):
failures.append("tactic target click should commit the moved caster action")
if int(enemy.get("hp", 0)) >= enemy_hp_before and bool(enemy.get("alive", true)):
failures.append("tactic target click should damage or defeat the target")
if scene.state.get_battle_gold_reward() != 44:
failures.append("tactic target click should commit deferred movement event before resolving tactic")
scene.free()
func _check_scene_post_move_item_picker_flow(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 item picker flow")
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()
scene.state.set_inventory_snapshot({"bean": 1})
var ally: Dictionary = scene.state.get_unit("xiahou_dun")
ally["pos"] = Vector2i(2, 4)
ally["hp"] = 12
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["mp"] = 10
scene.state.battle_events.append({
"id": "pending_item_gold",
"once": true,
"when": {
"type": "unit_reaches_tile",
"unit_ids": ["cao_cao"],
"pos": [2, 3]
},
"actions": [{"type": "grant_gold", "amount": 55}]
})
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for item picker flow")
scene.free()
return
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(2, 3)))
if scene.post_move_item_button == null or scene.post_move_item_button.disabled:
failures.append("post-move Item should be enabled when Bean can heal an adjacent ally")
scene._on_post_move_item_pressed()
if scene.post_move_picker_panel == null or not scene.post_move_picker_panel.visible or scene.post_move_picker_mode != "item":
failures.append("post-move Item should open the local item picker")
if scene.post_move_menu != null and scene.post_move_menu.visible:
failures.append("post-move action menu should hide while the item picker is open")
if scene.item_menu != null and scene.item_menu.visible:
failures.append("post-move local item picker should not use the side item menu")
scene._on_post_move_item_option_pressed("bean")
if scene.selected_item_id != "bean":
failures.append("choosing Bean should enter item targeting mode")
if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible:
failures.append("item picker should hide after choosing an item")
if scene.targeting_hint_panel == null or not scene.targeting_hint_panel.visible:
failures.append("targeting hint panel should be visible during item targeting")
if not scene.item_cells.has(Vector2i(2, 4)):
failures.append("Bean targeting should highlight the adjacent injured ally")
scene._on_targeting_back_pressed()
cao_cao = scene.state.get_unit("cao_cao")
if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("item Back should return to the post-move action menu")
if scene.selected_item_id != "":
failures.append("item Back should clear the selected item")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("item Back should preserve the pending moved unit without acting")
scene._on_post_move_item_pressed()
scene._on_post_move_item_option_pressed("bean")
var ally_hp_before := int(ally.get("hp", 0))
scene._handle_board_click(_screen_for_cell(Vector2i(2, 4)))
cao_cao = scene.state.get_unit("cao_cao")
ally = scene.state.get_unit("xiahou_dun")
if scene._has_pending_move() or scene.selected_item_id != "":
failures.append("item target click should clear pending move and selected item")
if not scene.state.get_selected_unit().is_empty():
failures.append("item target click should clear selected unit after acting")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)):
failures.append("item target click should commit the moved user action")
if int(ally.get("hp", 0)) <= ally_hp_before:
failures.append("item target click should heal the selected ally")
if int(scene.state.get_inventory_snapshot().get("bean", 0)) != 0:
failures.append("item target click should consume the Bean")
if scene.state.get_battle_gold_reward() != 55:
failures.append("item target click should commit deferred movement event before resolving item")
scene.free()
func _screen_for_cell(cell: Vector2i) -> Vector2:
return BattleSceneScript.BOARD_OFFSET + Vector2(cell.x, cell.y) * BattleSceneScript.TILE_SIZE + Vector2.ONE * (BattleSceneScript.TILE_SIZE * 0.5)