From 3add6bab4ff755caa22b32e34ba8e650ef811983 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 21:39:15 +0900 Subject: [PATCH] Add post-move action menu flow --- scripts/core/battle_state.gd | 33 ++- scripts/scenes/battle_scene.gd | 391 +++++++++++++++++++++++++-- tools/smoke_post_move_action_flow.gd | 124 +++++++++ 3 files changed, 526 insertions(+), 22 deletions(-) create mode 100644 tools/smoke_post_move_action_flow.gd diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 3003372..bc0028d 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -639,7 +639,7 @@ func clear_selection() -> void: _notify_changed() -func try_move_selected(cell: Vector2i) -> bool: +func try_move_selected(cell: Vector2i, defer_reach_events := false) -> bool: var unit := get_selected_unit() if unit.is_empty() or battle_status != STATUS_ACTIVE: return false @@ -660,9 +660,40 @@ func try_move_selected(cell: Vector2i) -> bool: unit["moved"] = true unit_motion_requested.emit(str(unit.get("id", "")), from_cell, cell) _emit_log("%s moved to %s." % [unit["name"], _format_cell(cell)]) + if not defer_reach_events: + _run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit) + _check_battle_status() + _notify_changed() + return true + + +func commit_pending_move_events(unit_id: String) -> bool: + var unit := get_unit(unit_id) + if unit.is_empty() or battle_status != STATUS_ACTIVE: + return false _run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit) _check_battle_status() _notify_changed() + return battle_status == STATUS_ACTIVE + + +func cancel_pending_move(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) -> bool: + var unit := get_unit(unit_id) + if unit.is_empty() or battle_status != STATUS_ACTIVE: + return false + if bool(unit.get("acted", false)) or not bool(unit.get("moved", false)): + return false + if unit.get("pos", Vector2i(-1, -1)) != to_cell: + return false + var blocker := get_unit_at(from_cell) + if not blocker.is_empty() and str(blocker.get("id", "")) != unit_id: + return false + + unit["pos"] = from_cell + unit["moved"] = false + unit_motion_requested.emit(unit_id, to_cell, from_cell) + _emit_log("%s cancels movement." % unit["name"]) + _notify_changed() return true diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index a1ea4e3..40cf0e1 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -59,6 +59,8 @@ const DIALOGUE_COLUMN_SIZE := Vector2(820, 186) const DIALOGUE_TEXT_SIZE := Vector2(800, 104) 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) var state: BattleState = BattleStateScript.new() var campaign_state: CampaignState = CampaignStateScript.new() @@ -70,6 +72,10 @@ var item_cells: Array[Vector2i] = [] var threat_cells: Array[Vector2i] = [] var selected_skill_id := "" var selected_item_id := "" +var basic_attack_targeting := false +var pending_move_unit_id := "" +var pending_move_from_cell := Vector2i(-1, -1) +var pending_move_to_cell := Vector2i(-1, -1) var show_threat_overlay := false var active_scenario_id := "" var battle_started := false @@ -158,6 +164,13 @@ var item_list: VBoxContainer var equip_button: Button var equip_menu: VBoxContainer var equip_list: VBoxContainer +var post_move_menu: PanelContainer +var post_move_title_label: Label +var post_move_attack_button: Button +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 threat_button: Button var restart_button: Button var new_campaign_button: Button @@ -220,6 +233,8 @@ func _unhandled_input(event: InputEvent) -> void: queue_redraw() elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: _handle_board_click(event.position) + elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: + _handle_cancel_input() elif event is InputEventKey and event.pressed and not event.echo: _handle_key(event) @@ -429,6 +444,58 @@ func _create_hud() -> void: equip_list.add_theme_constant_override("separation", 4) equip_menu.add_child(equip_list) + post_move_menu = PanelContainer.new() + post_move_menu.visible = false + post_move_menu.mouse_filter = Control.MOUSE_FILTER_STOP + post_move_menu.custom_minimum_size = POST_MOVE_MENU_SIZE + post_move_menu.size = POST_MOVE_MENU_SIZE + root.add_child(post_move_menu) + + var post_move_column := VBoxContainer.new() + post_move_column.add_theme_constant_override("separation", 4) + post_move_menu.add_child(post_move_column) + + post_move_title_label = Label.new() + post_move_title_label.text = "Choose Action" + post_move_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + post_move_column.add_child(post_move_title_label) + + var post_move_grid := GridContainer.new() + post_move_grid.columns = 2 + post_move_grid.add_theme_constant_override("h_separation", 6) + post_move_grid.add_theme_constant_override("v_separation", 4) + post_move_column.add_child(post_move_grid) + + post_move_attack_button = Button.new() + post_move_attack_button.text = "Attack" + post_move_attack_button.tooltip_text = "Choose an enemy target from this unit's current position." + post_move_attack_button.pressed.connect(_on_post_move_attack_pressed) + post_move_grid.add_child(post_move_attack_button) + + post_move_tactic_button = Button.new() + post_move_tactic_button.text = "Tactic" + post_move_tactic_button.tooltip_text = "Open tactics after this move." + post_move_tactic_button.pressed.connect(_on_post_move_tactic_pressed) + post_move_grid.add_child(post_move_tactic_button) + + post_move_item_button = Button.new() + post_move_item_button.text = "Item" + post_move_item_button.tooltip_text = "Open consumable items after this move." + post_move_item_button.pressed.connect(_on_post_move_item_pressed) + post_move_grid.add_child(post_move_item_button) + + post_move_wait_button = Button.new() + post_move_wait_button.text = "Wait" + post_move_wait_button.tooltip_text = "Commit this move and end the unit's action." + post_move_wait_button.pressed.connect(_on_post_move_wait_pressed) + post_move_grid.add_child(post_move_wait_button) + + post_move_cancel_button = Button.new() + post_move_cancel_button.text = "Cancel Move" + post_move_cancel_button.tooltip_text = "Return to the starting cell." + post_move_cancel_button.pressed.connect(_on_post_move_cancel_pressed) + post_move_column.add_child(post_move_cancel_button) + log_box = RichTextLabel.new() log_box.custom_minimum_size = Vector2(420, 70) log_box.fit_content = false @@ -938,50 +1005,173 @@ func _handle_board_click(screen_position: Vector2) -> void: if _can_select(clicked_unit): selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false + _clear_pending_move_state() state.select_unit(clicked_unit["id"]) return if not selected_item_id.is_empty(): - if state.try_use_selected_item_on_cell(selected_item_id, cell): + if item_cells.has(cell) and _commit_pending_move_before_action() and state.try_use_selected_item_on_cell(selected_item_id, cell): selected_item_id = "" + basic_attack_targeting = false + _clear_pending_move_state() + return + if _has_pending_move(): return if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit): selected_item_id = "" + basic_attack_targeting = false state.select_unit(clicked_unit["id"]) return if not selected_skill_id.is_empty(): - if state.try_cast_selected_skill(selected_skill_id, cell): + if skill_cells.has(cell) and _commit_pending_move_before_action() and state.try_cast_selected_skill(selected_skill_id, cell): selected_skill_id = "" + basic_attack_targeting = false + _clear_pending_move_state() + return + if _has_pending_move(): return if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit): selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false state.select_unit(clicked_unit["id"]) return if not clicked_unit.is_empty() and clicked_unit.get("team", "") != selected_unit.get("team", ""): - if state.try_attack_selected(clicked_unit["id"]): + if _has_pending_move() and not basic_attack_targeting: + return + var target_cell: Vector2i = clicked_unit.get("pos", cell) + if attack_cells.has(target_cell) and _commit_pending_move_before_action() and state.try_attack_selected(clicked_unit["id"]): selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false + _clear_pending_move_state() return if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", ""): + if _has_pending_move(): + return if _can_select(clicked_unit): selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false + _clear_pending_move_state() state.select_unit(clicked_unit["id"]) return - if state.try_move_selected(cell): + var selected_start_cell: Vector2i = selected_unit.get("pos", Vector2i(-1, -1)) + if not _has_pending_move() and state.try_move_selected(cell, true): + pending_move_unit_id = str(selected_unit.get("id", "")) + pending_move_from_cell = selected_start_cell + pending_move_to_cell = cell + selected_skill_id = "" + selected_item_id = "" + basic_attack_targeting = false + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _refresh_ranges() + _show_post_move_menu() + _update_hud() + queue_redraw() return if _can_select(clicked_unit): selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false + _clear_pending_move_state() state.select_unit(clicked_unit["id"]) +func _has_pending_move() -> bool: + return ( + not pending_move_unit_id.is_empty() + and pending_move_from_cell != Vector2i(-1, -1) + and pending_move_to_cell != Vector2i(-1, -1) + ) + + +func _clear_pending_move_state(hide_menu := true) -> void: + pending_move_unit_id = "" + pending_move_from_cell = Vector2i(-1, -1) + pending_move_to_cell = Vector2i(-1, -1) + basic_attack_targeting = false + if hide_menu: + _hide_post_move_menu() + + +func _commit_pending_move_before_action() -> bool: + if not _has_pending_move(): + return true + var unit_id := pending_move_unit_id + _clear_pending_move_state() + var still_active := state.commit_pending_move_events(unit_id) + return still_active and state.can_player_act() and not state.get_selected_unit().is_empty() and not _is_input_locked() + + +func _cancel_pending_move() -> bool: + if not _has_pending_move(): + return false + var unit_id := pending_move_unit_id + var from_cell := pending_move_from_cell + var to_cell := pending_move_to_cell + selected_skill_id = "" + selected_item_id = "" + basic_attack_targeting = false + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _hide_post_move_menu() + if state.cancel_pending_move(unit_id, from_cell, to_cell): + _clear_pending_move_state() + _play_ui_cancel() + _refresh_ranges() + _update_hud() + queue_redraw() + return true + return false + + +func _handle_cancel_input() -> bool: + if _has_pending_move(): + return _cancel_pending_move() + if tactic_menu != null and tactic_menu.visible: + _play_ui_cancel() + _hide_tactic_menu() + _update_hud() + queue_redraw() + return true + if item_menu != null and item_menu.visible: + _play_ui_cancel() + _hide_item_menu() + _update_hud() + queue_redraw() + return true + if equip_menu != null and equip_menu.visible: + _play_ui_cancel() + _hide_equip_menu() + _update_hud() + queue_redraw() + return true + if not selected_skill_id.is_empty() or not selected_item_id.is_empty() or basic_attack_targeting: + _play_ui_cancel() + selected_skill_id = "" + selected_item_id = "" + basic_attack_targeting = false + _refresh_ranges() + _update_hud() + queue_redraw() + return true + if not state.get_selected_unit().is_empty(): + _play_ui_cancel() + state.clear_selection() + return true + return false + + func _handle_key(event: InputEventKey) -> void: if event.keycode == KEY_ENTER and briefing_panel != null and briefing_panel.visible: if _is_prep_menu_visible(): @@ -1006,21 +1196,10 @@ func _handle_key(event: InputEventKey) -> void: _hide_item_menu() elif equip_menu != null and equip_menu.visible: _hide_equip_menu() - elif selected_skill_id.is_empty() and selected_item_id.is_empty(): - state.clear_selection() else: - selected_skill_id = "" - selected_item_id = "" - _refresh_ranges() - _update_hud() - queue_redraw() + _handle_cancel_input() elif event.keycode == KEY_SPACE and not _is_input_locked(): - selected_skill_id = "" - selected_item_id = "" - _hide_tactic_menu() - _hide_item_menu() - _hide_equip_menu() - state.end_player_turn() + _on_end_turn_pressed() elif event.keycode == KEY_R and not _is_prep_menu_visible(): _on_restart_pressed() elif event.keycode == KEY_T and not _is_input_locked(): @@ -1346,6 +1525,11 @@ func _draw_overlays() -> void: for cell in move_cells: draw_rect(_rect_for_cell(cell), Color(0.16, 0.55, 0.95, 0.28)) + if _has_pending_move() and state.is_inside(pending_move_to_cell): + var pending_rect := _rect_for_cell(pending_move_to_cell) + draw_rect(pending_rect.grow(-4.0), Color(1.0, 0.82, 0.28, 0.32)) + draw_rect(pending_rect.grow(-7.0), Color(1.0, 0.88, 0.36, 0.86), false, 2.0) + for cell in attack_cells: draw_rect(_rect_for_cell(cell), Color(0.95, 0.22, 0.18, 0.24)) @@ -1847,6 +2031,8 @@ func _target_preview_badge() -> Dictionary: return _skill_target_preview_badge(selected, target) if not selected_item_id.is_empty(): return _item_target_preview_badge(selected, target) + if _has_pending_move() and not basic_attack_targeting: + return {} var attack_badge := _attack_target_preview_badge(selected, target) if not attack_badge.is_empty(): return attack_badge @@ -1942,6 +2128,8 @@ func _item_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dic func _hover_intent_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary: if selected.is_empty(): return {} + if _has_pending_move(): + return {} if not state.can_player_act(): return {} if not bool(selected.get("controllable", true)) or bool(selected.get("acted", false)): @@ -2039,20 +2227,24 @@ func _refresh_ranges() -> void: if selected.is_empty(): selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false return move_cells = state.get_movement_range(selected["id"]) if not selected_skill_id.is_empty(): skill_cells = state.get_skill_cells(selected["id"], selected_skill_id) if skill_cells.is_empty(): selected_skill_id = "" - attack_cells = state.get_attack_cells(selected["id"]) + if not _has_pending_move() or basic_attack_targeting: + attack_cells = state.get_attack_cells(selected["id"]) elif not selected_item_id.is_empty(): item_cells = state.get_item_target_cells(selected["id"], selected_item_id) if item_cells.is_empty(): selected_item_id = "" - attack_cells = state.get_attack_cells(selected["id"]) + if not _has_pending_move() or basic_attack_targeting: + attack_cells = state.get_attack_cells(selected["id"]) else: - attack_cells = state.get_attack_cells(selected["id"]) + if not _has_pending_move() or basic_attack_targeting: + attack_cells = state.get_attack_cells(selected["id"]) func _update_hud() -> void: @@ -2098,6 +2290,7 @@ func _update_hud() -> void: _update_end_turn_button() restart_button.disabled = campaign_complete_screen _update_threat_button() + _update_post_move_menu() func _format_objective_hud_text() -> String: @@ -2666,6 +2859,7 @@ func _on_state_changed() -> void: if state.get_selected_unit().is_empty(): selected_skill_id = "" selected_item_id = "" + _clear_pending_move_state() _refresh_ranges() _update_hud() queue_redraw() @@ -2914,9 +3108,11 @@ func _show_next_dialogue() -> void: return selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false _hide_tactic_menu() _hide_item_menu() _hide_equip_menu() + _hide_post_move_menu() _refresh_ranges() active_dialogue_lines = dialogue_queue.pop_front() active_dialogue_index = 0 @@ -3066,18 +3262,29 @@ func _is_dialogue_visible() -> bool: func _on_wait_pressed() -> void: selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false _hide_tactic_menu() _hide_item_menu() _hide_equip_menu() - if state.try_wait_selected(): + if _commit_pending_move_before_action() and state.try_wait_selected(): _play_ui_confirm() func _on_end_turn_pressed() -> void: if state.can_player_act(): _play_ui_confirm() + if _has_pending_move(): + selected_skill_id = "" + selected_item_id = "" + basic_attack_targeting = false + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + if not (_commit_pending_move_before_action() and state.try_wait_selected()): + return selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false _hide_tactic_menu() _hide_item_menu() _hide_equip_menu() @@ -3091,6 +3298,7 @@ func _on_tactic_pressed() -> void: if state.is_unit_skill_sealed(selected["id"]): _play_ui_cancel() selected_skill_id = "" + basic_attack_targeting = false _hide_tactic_menu() _refresh_ranges() _update_hud() @@ -3099,8 +3307,12 @@ func _on_tactic_pressed() -> void: _play_ui_click() if tactic_menu != null and tactic_menu.visible: _hide_tactic_menu() + if _has_pending_move(): + _show_post_move_menu() else: selected_item_id = "" + basic_attack_targeting = false + _hide_post_move_menu() _hide_item_menu() _hide_equip_menu() _show_tactic_menu(selected) @@ -3116,8 +3328,12 @@ func _on_item_pressed() -> void: _play_ui_click() if item_menu != null and item_menu.visible: _hide_item_menu() + if _has_pending_move(): + _show_post_move_menu() else: selected_skill_id = "" + basic_attack_targeting = false + _hide_post_move_menu() _hide_tactic_menu() _hide_equip_menu() _show_item_menu(selected) @@ -3138,8 +3354,10 @@ func _on_equip_pressed() -> void: else: selected_skill_id = "" selected_item_id = "" + basic_attack_targeting = false _hide_tactic_menu() _hide_item_menu() + _hide_post_move_menu() _show_equip_menu(selected) _refresh_ranges() _update_hud() @@ -3172,6 +3390,7 @@ func _on_restart_pressed() -> void: shop_sell_mode = false selected_skill_id = "" selected_item_id = "" + _clear_pending_move_state() armory_unit_id = "" formation_unit_id = "" _hide_tactic_menu() @@ -3216,6 +3435,7 @@ func _load_scenario(scenario_id: String) -> void: unit_motion_by_unit.clear() selected_skill_id = "" selected_item_id = "" + _clear_pending_move_state() _hide_tactic_menu() _hide_item_menu() _hide_equip_menu() @@ -3358,6 +3578,7 @@ func _show_briefing() -> void: _hide_roster_menu() _hide_formation_menu() _hide_save_menu() + _clear_pending_move_state() var prep_locked := _is_prebattle_prep_locked() if chapter_button != null: chapter_button.disabled = campaign_state.get_chapter_progress_entries().is_empty() @@ -3392,6 +3613,7 @@ func _start_battle_from_briefing() -> void: _hide_roster_menu() _hide_formation_menu() _hide_save_menu() + _clear_pending_move_state() state.run_battle_begin_events() _show_next_dialogue() _update_hud() @@ -3914,6 +4136,7 @@ func _reload_from_campaign_state() -> void: unit_motion_by_unit.clear() selected_skill_id = "" selected_item_id = "" + _clear_pending_move_state() armory_unit_id = "" formation_unit_id = "" _hide_tactic_menu() @@ -4497,6 +4720,7 @@ func _try_show_post_battle_dialogue() -> bool: dialogue_queue.clear() selected_skill_id = "" selected_item_id = "" + _clear_pending_move_state() _hide_tactic_menu() _hide_item_menu() _hide_equip_menu() @@ -4834,6 +5058,7 @@ func _show_campaign_complete() -> void: item_cells.clear() selected_skill_id = "" selected_item_id = "" + _clear_pending_move_state() _hide_tactic_menu() _hide_item_menu() _hide_equip_menu() @@ -4870,6 +5095,7 @@ func _on_new_campaign_pressed() -> void: post_battle_dialogue_started = false selected_skill_id = "" selected_item_id = "" + _clear_pending_move_state() formation_unit_id = "" _hide_tactic_menu() _hide_item_menu() @@ -4885,6 +5111,119 @@ func _on_new_campaign_pressed() -> void: _update_hud() +func _show_post_move_menu() -> void: + if post_move_menu == null or not _has_pending_move(): + return + _update_post_move_menu() + post_move_menu.visible = true + post_move_menu.move_to_front() + + +func _hide_post_move_menu() -> void: + if post_move_menu == null: + return + post_move_menu.visible = false + + +func _update_post_move_menu() -> void: + if post_move_menu == null: + return + if not _has_pending_move(): + _hide_post_move_menu() + 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_menu() + return + _position_post_move_menu() + if post_move_title_label != null: + post_move_title_label.text = "%s: choose action" % str(selected.get("name", "Unit")) + if post_move_attack_button != null: + var attack_blocked := state.get_attack_cells(pending_move_unit_id).is_empty() + 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() + 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." + 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 + post_move_item_button.tooltip_text = "No usable item target is in range." if item_blocked else "Open consumable items after this move." + if post_move_wait_button != null: + post_move_wait_button.disabled = false + post_move_wait_button.tooltip_text = "Commit this move and end the unit's action." + if post_move_cancel_button != null: + post_move_cancel_button.disabled = false + post_move_cancel_button.tooltip_text = "Right-click also returns to the starting cell." + + +func _position_post_move_menu() -> void: + if post_move_menu == null: + return + var cell_rect := _rect_for_cell(pending_move_to_cell) + var next_position := cell_rect.position + Vector2(TILE_SIZE, 0.0) + POST_MOVE_MENU_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_MENU_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_menu.position = next_position + + +func _on_post_move_attack_pressed() -> void: + if not _has_pending_move(): + return + if state.get_attack_cells(pending_move_unit_id).is_empty(): + _play_ui_cancel() + return + _play_ui_click() + selected_skill_id = "" + selected_item_id = "" + basic_attack_targeting = true + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _hide_post_move_menu() + _refresh_ranges() + _update_hud() + queue_redraw() + + +func _on_post_move_tactic_pressed() -> void: + if not _has_pending_move(): + return + _hide_post_move_menu() + basic_attack_targeting = false + _on_tactic_pressed() + + +func _on_post_move_item_pressed() -> void: + if not _has_pending_move(): + return + _hide_post_move_menu() + basic_attack_targeting = false + _on_item_pressed() + + +func _on_post_move_wait_pressed() -> void: + selected_skill_id = "" + selected_item_id = "" + basic_attack_targeting = false + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + if _commit_pending_move_before_action() and state.try_wait_selected(): + _play_ui_confirm() + + +func _on_post_move_cancel_pressed() -> void: + _cancel_pending_move() + + func _set_action_button_state(button: Button, label: String, disabled: bool, tooltip := "") -> void: if button == null: return @@ -5129,9 +5468,11 @@ func _on_tactic_skill_pressed(skill_id: String) -> void: _play_ui_confirm() selected_skill_id = skill_id selected_item_id = "" + basic_attack_targeting = false _hide_tactic_menu() _hide_item_menu() _hide_equip_menu() + _hide_post_move_menu() _refresh_ranges() _update_hud() queue_redraw() @@ -5140,7 +5481,10 @@ func _on_tactic_skill_pressed(skill_id: String) -> void: func _on_tactic_cancel_pressed() -> void: _play_ui_cancel() selected_skill_id = "" + basic_attack_targeting = false _hide_tactic_menu() + if _has_pending_move(): + _show_post_move_menu() _refresh_ranges() _update_hud() queue_redraw() @@ -5252,9 +5596,11 @@ func _on_item_selected_pressed(item_id: String) -> void: _play_ui_confirm() selected_item_id = item_id selected_skill_id = "" + basic_attack_targeting = false _hide_item_menu() _hide_tactic_menu() _hide_equip_menu() + _hide_post_move_menu() _refresh_ranges() _update_hud() queue_redraw() @@ -5263,7 +5609,10 @@ func _on_item_selected_pressed(item_id: String) -> void: func _on_item_cancel_pressed() -> void: _play_ui_cancel() selected_item_id = "" + basic_attack_targeting = false _hide_item_menu() + if _has_pending_move(): + _show_post_move_menu() _refresh_ranges() _update_hud() queue_redraw() diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd new file mode 100644 index 0000000..b116cd1 --- /dev/null +++ b/tools/smoke_post_move_action_flow.gd @@ -0,0 +1,124 @@ +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_deferred_move_events(failures) + _check_scene_post_move_menu_flow(failures) + + if failures.is_empty(): + print("post move action flow smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_deferred_move_events(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for pending move state") + return + + var from_cell := Vector2i(1, 3) + var to_cell := Vector2i(2, 3) + state.battle_events.append({ + "id": "pending_move_gold", + "once": true, + "when": { + "type": "unit_reaches_tile", + "unit_ids": ["cao_cao"], + "pos": [to_cell.x, to_cell.y] + }, + "actions": [{"type": "grant_gold", "amount": 77}] + }) + + if not state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for pending move state") + return + if not state.try_move_selected(to_cell, true): + failures.append("tentative move should succeed") + return + + var cao_cao: Dictionary = state.get_unit("cao_cao") + if cao_cao.get("pos", Vector2i.ZERO) != to_cell: + failures.append("tentative move should update position") + if not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): + failures.append("tentative move should mark moved but not acted") + if state.get_battle_gold_reward() != 0: + failures.append("tentative move should defer unit_reaches_tile rewards") + + if not state.cancel_pending_move("cao_cao", from_cell, to_cell): + failures.append("pending move cancel should succeed") + cao_cao = state.get_unit("cao_cao") + if cao_cao.get("pos", Vector2i.ZERO) != from_cell or bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): + failures.append("pending move cancel should restore original action state") + if state.get_battle_gold_reward() != 0: + failures.append("canceled pending move should not fire movement event") + + if not state.try_move_selected(to_cell, true): + failures.append("second tentative move should succeed") + if not state.commit_pending_move_events("cao_cao"): + failures.append("pending move commit should keep battle active") + if state.get_battle_gold_reward() != 77: + failures.append("pending move commit should fire deferred movement event") + + +func _check_scene_post_move_menu_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 scene 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() + + if not scene.state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for scene flow") + scene.free() + return + scene._refresh_ranges() + scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) + + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + if not scene._has_pending_move(): + failures.append("scene should keep pending move metadata after move click") + if scene.post_move_menu == null or not scene.post_move_menu.visible: + failures.append("post-move action menu should be visible after moving") + 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("scene move should be pending with action still available") + if scene.state.get_selected_unit().is_empty(): + failures.append("scene should keep moved unit selected for follow-up action") + if not scene.attack_cells.is_empty(): + failures.append("attack cells should wait until Attack is chosen from the post-move menu") + + scene._handle_cancel_input() + cao_cao = scene.state.get_unit("cao_cao") + if scene._has_pending_move() or (scene.post_move_menu != null and scene.post_move_menu.visible): + failures.append("right-click cancel handler should clear pending move menu state") + if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(1, 3) or bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): + failures.append("right-click cancel handler should restore the starting cell") + + scene._refresh_ranges() + scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) + scene._on_post_move_wait_pressed() + cao_cao = scene.state.get_unit("cao_cao") + 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("post-move Wait should commit movement and end the unit action") + if not scene.state.get_selected_unit().is_empty() or scene._has_pending_move(): + failures.append("post-move Wait should clear selection and pending metadata") + + 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)