diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 6b356d4..543a4a6 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -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() diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd index 2f9556a..50be6d6 100644 --- a/tools/smoke_post_move_action_flow.gd +++ b/tools/smoke_post_move_action_flow.gd @@ -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)