From e5d4b4f0105afe0235b6eb9f2d92907aa69c93a2 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 22:07:06 +0900 Subject: [PATCH] Add post-move attack targeting hints --- scripts/scenes/battle_scene.gd | 218 ++++++++++++++++++++++++++- tools/smoke_post_move_action_flow.gd | 89 +++++++++++ 2 files changed, 305 insertions(+), 2 deletions(-) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 40cf0e1..6b356d4 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 TARGETING_HINT_PANEL_SIZE := Vector2(240, 94) +const TARGETING_HINT_PANEL_OFFSET := Vector2(18, 88) var state: BattleState = BattleStateScript.new() var campaign_state: CampaignState = CampaignStateScript.new() @@ -171,6 +173,11 @@ 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 targeting_hint_panel: PanelContainer +var targeting_hint_title_label: Label +var targeting_hint_detail_label: Label +var targeting_hint_back_button: Button +var targeting_hint_cancel_move_button: Button var threat_button: Button var restart_button: Button var new_campaign_button: Button @@ -243,6 +250,7 @@ func _draw() -> void: _draw_map() _draw_overlays() _draw_units() + _draw_attack_target_markers() _draw_attack_effects() _draw_target_preview_badge() _draw_floating_texts() @@ -496,6 +504,43 @@ func _create_hud() -> void: post_move_cancel_button.pressed.connect(_on_post_move_cancel_pressed) post_move_column.add_child(post_move_cancel_button) + targeting_hint_panel = PanelContainer.new() + targeting_hint_panel.visible = false + targeting_hint_panel.mouse_filter = Control.MOUSE_FILTER_STOP + targeting_hint_panel.custom_minimum_size = TARGETING_HINT_PANEL_SIZE + targeting_hint_panel.size = TARGETING_HINT_PANEL_SIZE + root.add_child(targeting_hint_panel) + + var targeting_column := VBoxContainer.new() + targeting_column.add_theme_constant_override("separation", 4) + targeting_hint_panel.add_child(targeting_column) + + targeting_hint_title_label = Label.new() + targeting_hint_title_label.text = "Choose Target" + targeting_hint_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + targeting_column.add_child(targeting_hint_title_label) + + targeting_hint_detail_label = Label.new() + targeting_hint_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + targeting_hint_detail_label.custom_minimum_size = Vector2(220, 28) + targeting_column.add_child(targeting_hint_detail_label) + + var targeting_button_row := HBoxContainer.new() + targeting_button_row.add_theme_constant_override("separation", 6) + targeting_column.add_child(targeting_button_row) + + targeting_hint_back_button = Button.new() + targeting_hint_back_button.text = "Back" + targeting_hint_back_button.tooltip_text = "Return to the action menu." + targeting_hint_back_button.pressed.connect(_on_targeting_back_pressed) + targeting_button_row.add_child(targeting_hint_back_button) + + targeting_hint_cancel_move_button = Button.new() + targeting_hint_cancel_move_button.text = "Cancel Move" + targeting_hint_cancel_move_button.tooltip_text = "Return to the starting cell." + targeting_hint_cancel_move_button.pressed.connect(_on_targeting_cancel_move_pressed) + targeting_button_row.add_child(targeting_hint_cancel_move_button) + log_box = RichTextLabel.new() log_box.custom_minimum_size = Vector2(420, 70) log_box.fit_content = false @@ -1101,6 +1146,7 @@ func _clear_pending_move_state(hide_menu := true) -> void: basic_attack_targeting = false if hide_menu: _hide_post_move_menu() + _hide_targeting_hint_panel() func _commit_pending_move_before_action() -> bool: @@ -1161,6 +1207,9 @@ func _handle_cancel_input() -> bool: selected_skill_id = "" selected_item_id = "" basic_attack_targeting = false + _hide_targeting_hint_panel() + if _has_pending_move(): + _show_post_move_menu() _refresh_ranges() _update_hud() queue_redraw() @@ -1608,6 +1657,63 @@ func _draw_units() -> void: draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0) +func _draw_attack_target_markers() -> void: + if not basic_attack_targeting: + return + var font := ThemeDB.fallback_font + for marker in _attack_target_marker_entries(): + var cell: Vector2i = marker.get("cell", Vector2i(-1, -1)) + if not state.is_inside(cell): + continue + var rect := _rect_for_cell(cell) + var marker_rect := Rect2(rect.position + Vector2(7.0, TILE_SIZE - 24.0), Vector2(TILE_SIZE - 14.0, 18.0)) + var color := _target_preview_badge_color(str(marker.get("kind", "damage"))) + draw_rect(rect.grow(-3.0), Color(1.0, 0.22, 0.16, 0.18)) + draw_rect(rect.grow(-6.0), color, false, 2.2) + draw_rect(marker_rect, Color(0.025, 0.028, 0.034, 0.88)) + draw_rect(marker_rect, color, false, 1.4) + draw_string(font, marker_rect.position + Vector2(0, 13), str(marker.get("text", "TARGET")), HORIZONTAL_ALIGNMENT_CENTER, marker_rect.size.x, 12, color) + + +func _attack_target_marker_entries() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if not basic_attack_targeting: + return result + var selected := state.get_selected_unit() + if selected.is_empty(): + return result + for cell in attack_cells: + var target := state.get_unit_at(cell) + if target.is_empty() or target.get("team", "") == selected.get("team", ""): + continue + var preview := state.get_damage_preview(str(selected.get("id", "")), str(target.get("id", ""))) + if preview.is_empty() or not bool(preview.get("in_range", false)) or bool(preview.get("attack_locked", false)): + continue + var hit_chance := int(preview.get("hit_chance", 100)) + var result_text := "KO" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0)) + result.append({ + "cell": cell, + "target_id": str(target.get("id", "")), + "text": "%s %d%%" % [result_text, hit_chance], + "kind": "damage" + }) + return result + + +func _has_basic_attack_target(unit_id: String) -> bool: + var unit := state.get_unit(unit_id) + if unit.is_empty(): + return false + for cell in state.get_attack_cells(unit_id): + var target := state.get_unit_at(cell) + if target.is_empty() or target.get("team", "") == unit.get("team", ""): + continue + var preview := state.get_damage_preview(unit_id, str(target.get("id", ""))) + if not preview.is_empty() and bool(preview.get("in_range", false)) and not bool(preview.get("attack_locked", false)): + return true + return false + + func _fit_texture_rect(texture: Texture2D, target: Rect2) -> Rect2: var texture_size := texture.get_size() if texture_size.x <= 0.0 or texture_size.y <= 0.0: @@ -2291,6 +2397,7 @@ func _update_hud() -> void: restart_button.disabled = campaign_complete_screen _update_threat_button() _update_post_move_menu() + _update_targeting_hint_panel() func _format_objective_hud_text() -> String: @@ -5139,7 +5246,7 @@ func _update_post_move_menu() -> void: 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() + var attack_blocked := not _has_basic_attack_target(pending_move_unit_id) 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: @@ -5177,7 +5284,7 @@ func _position_post_move_menu() -> void: func _on_post_move_attack_pressed() -> void: if not _has_pending_move(): return - if state.get_attack_cells(pending_move_unit_id).is_empty(): + if not _has_basic_attack_target(pending_move_unit_id): _play_ui_cancel() return _play_ui_click() @@ -5224,6 +5331,113 @@ func _on_post_move_cancel_pressed() -> void: _cancel_pending_move() +func _is_targeting_mode() -> bool: + return basic_attack_targeting or not selected_skill_id.is_empty() or not selected_item_id.is_empty() + + +func _show_targeting_hint_panel() -> void: + if targeting_hint_panel == null: + return + _update_targeting_hint_panel() + if targeting_hint_panel != null and _is_targeting_mode(): + targeting_hint_panel.visible = true + targeting_hint_panel.move_to_front() + + +func _hide_targeting_hint_panel() -> void: + if targeting_hint_panel == null: + return + targeting_hint_panel.visible = false + + +func _update_targeting_hint_panel() -> void: + if targeting_hint_panel == null: + return + if not _is_targeting_mode(): + _hide_targeting_hint_panel() + return + var selected := state.get_selected_unit() + if selected.is_empty() or bool(selected.get("acted", false)) or _is_input_locked(): + _hide_targeting_hint_panel() + return + _position_targeting_hint_panel(selected) + if targeting_hint_title_label != null: + targeting_hint_title_label.text = _targeting_hint_title_text() + if targeting_hint_detail_label != null: + targeting_hint_detail_label.text = _targeting_hint_detail_text() + if targeting_hint_back_button != null: + targeting_hint_back_button.text = "Back" + targeting_hint_back_button.tooltip_text = "Return to the action menu." if _has_pending_move() else "Clear target selection." + if targeting_hint_cancel_move_button != null: + targeting_hint_cancel_move_button.visible = _has_pending_move() + targeting_hint_cancel_move_button.disabled = not _has_pending_move() + targeting_hint_cancel_move_button.tooltip_text = "Right-click also returns to the starting cell." + targeting_hint_panel.visible = true + + +func _position_targeting_hint_panel(selected: Dictionary) -> void: + if targeting_hint_panel == null: + return + var cell: Vector2i = selected.get("pos", Vector2i(-1, -1)) + if not state.is_inside(cell): + return + var cell_rect := _rect_for_cell(cell) + var next_position := cell_rect.position + TARGETING_HINT_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 - TARGETING_HINT_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) + targeting_hint_panel.position = next_position + + +func _targeting_hint_title_text() -> String: + if basic_attack_targeting: + return "Select Attack Target" + if not selected_skill_id.is_empty(): + var skill := state.get_skill_def(selected_skill_id) + return "Target: %s" % str(skill.get("name", selected_skill_id)) + if not selected_item_id.is_empty(): + var item := state.get_item_def(selected_item_id) + return "Target: %s" % str(item.get("name", selected_item_id)) + return "Choose Target" + + +func _targeting_hint_detail_text() -> String: + if basic_attack_targeting: + var markers := _attack_target_marker_entries() + if markers.is_empty(): + return "No enemy is in range." + return "%d enemy target%s. Click a marked enemy." % [markers.size(), "" if markers.size() == 1 else "s"] + if not selected_skill_id.is_empty(): + return "Click a highlighted tactic cell." + if not selected_item_id.is_empty(): + return "Click a highlighted item target." + return "Choose a highlighted target." + + +func _on_targeting_back_pressed() -> void: + _play_ui_cancel() + selected_skill_id = "" + selected_item_id = "" + basic_attack_targeting = false + _hide_targeting_hint_panel() + _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_targeting_cancel_move_pressed() -> void: + _cancel_pending_move() + + func _set_action_button_state(button: Button, label: String, disabled: bool, tooltip := "") -> void: if button == null: return diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd index b116cd1..2f9556a 100644 --- a/tools/smoke_post_move_action_flow.gd +++ b/tools/smoke_post_move_action_flow.gd @@ -117,6 +117,95 @@ func _check_scene_post_move_menu_flow(failures: Array[String]) -> void: 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") + _check_scene_post_move_attack_targeting(failures) + + scene.free() + + +func _check_scene_post_move_attack_targeting(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 attack targeting 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"] = 24 + enemy["def"] = 1 + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + cao_cao["agi"] = 999 + cao_cao["atk"] = 60 + scene.state.rng.seed = 1 + + scene.state.battle_events.append({ + "id": "pending_attack_gold", + "once": true, + "when": { + "type": "unit_reaches_tile", + "unit_ids": ["cao_cao"], + "pos": [2, 3] + }, + "actions": [{"type": "grant_gold", "amount": 33}] + }) + + if not scene.state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for attack targeting flow") + scene.free() + return + scene._refresh_ranges() + scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) + if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible: + failures.append("attack targeting setup should leave the post-move menu visible") + if scene.basic_attack_targeting: + failures.append("attack targeting should be off before pressing Attack") + if not scene.attack_cells.is_empty(): + failures.append("attack range should stay hidden before pressing Attack") + if scene.state.get_battle_gold_reward() != 0: + failures.append("attack setup should not commit pending move events early") + + scene._on_post_move_attack_pressed() + if not scene.basic_attack_targeting: + 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 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() + if markers.size() != 1 or markers[0].get("cell", Vector2i.ZERO) != Vector2i(3, 3): + failures.append("attack target markers should include only the reachable enemy: %s" % str(markers)) + + scene._on_targeting_back_pressed() + cao_cao = scene.state.get_unit("cao_cao") + if scene.basic_attack_targeting or not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible: + failures.append("Back 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("Back should preserve the pending moved unit without acting") + + scene._on_post_move_attack_pressed() + 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.basic_attack_targeting: + failures.append("attack click should clear pending move and targeting state") + if not scene.state.get_selected_unit().is_empty(): + failures.append("attack 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("attack click should commit the moved attacker action") + if int(enemy.get("hp", 0)) >= enemy_hp_before and bool(enemy.get("alive", true)): + failures.append("attack click should damage or defeat the target") + if scene.state.get_battle_gold_reward() != 33: + failures.append("attack click should commit deferred movement event before resolving attack") + scene.free()