diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c943c7b..d041ef4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,6 +61,6 @@ Inventory and campaign flags are copied from `CampaignState` into `BattleState` The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, dialogue lines can show optional cached portrait textures or speaker-initial fallback panels in an expanded visual-novel-style panel, can place the portrait on the left or right side of that panel, and expose progress plus previous-line controls while a sequence is open, the side HUD reuses cached officer portrait textures or class sprites for the selected unit or hovered unit and formats focus text with role, movement type, current terrain move cost, defense/avoid bonuses, core stats, status effects, guard-zone AI hints, and gear, board maps can draw a scenario background texture under translucent terrain overlays, connected road segments, terrain detail patterns, shoreline/castle/forest/hill edge blends, and compact recovery markers for healing terrain, board units render class sprite tokens or generic-enemy transparent class cutouts, team rings, class badges, generic-enemy marks, officer pins, low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, item buttons can show optional cached item icons, hover previews are rendered as target badges from existing `BattleState` forecast APIs, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, objective updates, AI target-focus changes, and item/gold pickup effects use distinct feedback, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, `BattleState.unit_action_motion_requested` keeps its rules-facing signal shape while the scene maps attacker class ids to infantry, archer, cavalry, command, and heavy attack presentation profiles, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. -Large tactical maps are presented through a fixed visible map window rather than by shrinking the whole board. `BattleScene` keeps a clamped `board_scroll_offset`, scrolls the board while the mouse is near or slightly outside the visible map edge, ignores board clicks outside the visible map window, and draws a small minimap plate inside the map view; clicking or dragging the minimap recenters the tactical viewport, while action feedback can focus the board on off-screen cells before motion or combat effects play. +Large tactical maps are presented through a fixed visible map window rather than by shrinking the whole board. `BattleScene` keeps a clamped `board_scroll_offset`, scrolls the board while the mouse is near or slightly outside the visible map edge, brightens the scrollable frame edge under the cursor, ignores board clicks outside the visible map window, and draws a small minimap plate inside the map view; clicking or dragging the minimap recenters the tactical viewport, while action feedback can focus the board on off-screen cells before motion or combat effects play. The `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 5df325f..76913dc 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -2839,25 +2839,64 @@ func _draw_edge_scroll_cues(view_rect: Rect2) -> void: if not _is_minimap_visible(): return var min_offset := _clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) - var cue_color := Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.18) - var hot_color := Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.36) - var strip := 14.0 + var mouse_position := view_rect.get_center() + if is_inside_tree(): + mouse_position = get_viewport().get_mouse_position() + var active_sides := _edge_scroll_active_sides(mouse_position, view_rect) if board_scroll_offset.x < -0.5: - draw_rect(Rect2(view_rect.position, Vector2(strip, view_rect.size.y)), cue_color) - draw_line(view_rect.position + Vector2(5.0, view_rect.size.y * 0.5), view_rect.position + Vector2(11.0, view_rect.size.y * 0.5 - 8.0), hot_color, 2.0) - draw_line(view_rect.position + Vector2(5.0, view_rect.size.y * 0.5), view_rect.position + Vector2(11.0, view_rect.size.y * 0.5 + 8.0), hot_color, 2.0) + var active_left := bool(active_sides.get("left", false)) + var strip_left := _edge_scroll_cue_strip_width(active_left) + var cue_color_left := _edge_scroll_cue_fill_color(active_left) + var line_color_left := _edge_scroll_cue_line_color(active_left) + draw_rect(Rect2(view_rect.position, Vector2(strip_left, view_rect.size.y)), cue_color_left) + draw_line(view_rect.position + Vector2(5.0, view_rect.size.y * 0.5), view_rect.position + Vector2(11.0, view_rect.size.y * 0.5 - 8.0), line_color_left, 2.0) + draw_line(view_rect.position + Vector2(5.0, view_rect.size.y * 0.5), view_rect.position + Vector2(11.0, view_rect.size.y * 0.5 + 8.0), line_color_left, 2.0) if board_scroll_offset.x > min_offset.x + 0.5: - draw_rect(Rect2(Vector2(view_rect.end.x - strip, view_rect.position.y), Vector2(strip, view_rect.size.y)), cue_color) - draw_line(Vector2(view_rect.end.x - 5.0, view_rect.position.y + view_rect.size.y * 0.5), Vector2(view_rect.end.x - 11.0, view_rect.position.y + view_rect.size.y * 0.5 - 8.0), hot_color, 2.0) - draw_line(Vector2(view_rect.end.x - 5.0, view_rect.position.y + view_rect.size.y * 0.5), Vector2(view_rect.end.x - 11.0, view_rect.position.y + view_rect.size.y * 0.5 + 8.0), hot_color, 2.0) + var active_right := bool(active_sides.get("right", false)) + var strip_right := _edge_scroll_cue_strip_width(active_right) + var cue_color_right := _edge_scroll_cue_fill_color(active_right) + var line_color_right := _edge_scroll_cue_line_color(active_right) + draw_rect(Rect2(Vector2(view_rect.end.x - strip_right, view_rect.position.y), Vector2(strip_right, view_rect.size.y)), cue_color_right) + draw_line(Vector2(view_rect.end.x - 5.0, view_rect.position.y + view_rect.size.y * 0.5), Vector2(view_rect.end.x - 11.0, view_rect.position.y + view_rect.size.y * 0.5 - 8.0), line_color_right, 2.0) + draw_line(Vector2(view_rect.end.x - 5.0, view_rect.position.y + view_rect.size.y * 0.5), Vector2(view_rect.end.x - 11.0, view_rect.position.y + view_rect.size.y * 0.5 + 8.0), line_color_right, 2.0) if board_scroll_offset.y < -0.5: - draw_rect(Rect2(view_rect.position, Vector2(view_rect.size.x, strip)), cue_color) - draw_line(view_rect.position + Vector2(view_rect.size.x * 0.5, 5.0), view_rect.position + Vector2(view_rect.size.x * 0.5 - 8.0, 11.0), hot_color, 2.0) - draw_line(view_rect.position + Vector2(view_rect.size.x * 0.5, 5.0), view_rect.position + Vector2(view_rect.size.x * 0.5 + 8.0, 11.0), hot_color, 2.0) + var active_up := bool(active_sides.get("up", false)) + var strip_up := _edge_scroll_cue_strip_width(active_up) + var cue_color_up := _edge_scroll_cue_fill_color(active_up) + var line_color_up := _edge_scroll_cue_line_color(active_up) + draw_rect(Rect2(view_rect.position, Vector2(view_rect.size.x, strip_up)), cue_color_up) + draw_line(view_rect.position + Vector2(view_rect.size.x * 0.5, 5.0), view_rect.position + Vector2(view_rect.size.x * 0.5 - 8.0, 11.0), line_color_up, 2.0) + draw_line(view_rect.position + Vector2(view_rect.size.x * 0.5, 5.0), view_rect.position + Vector2(view_rect.size.x * 0.5 + 8.0, 11.0), line_color_up, 2.0) if board_scroll_offset.y > min_offset.y + 0.5: - draw_rect(Rect2(Vector2(view_rect.position.x, view_rect.end.y - strip), Vector2(view_rect.size.x, strip)), cue_color) - draw_line(Vector2(view_rect.position.x + view_rect.size.x * 0.5, view_rect.end.y - 5.0), Vector2(view_rect.position.x + view_rect.size.x * 0.5 - 8.0, view_rect.end.y - 11.0), hot_color, 2.0) - draw_line(Vector2(view_rect.position.x + view_rect.size.x * 0.5, view_rect.end.y - 5.0), Vector2(view_rect.position.x + view_rect.size.x * 0.5 + 8.0, view_rect.end.y - 11.0), hot_color, 2.0) + var active_down := bool(active_sides.get("down", false)) + var strip_down := _edge_scroll_cue_strip_width(active_down) + var cue_color_down := _edge_scroll_cue_fill_color(active_down) + var line_color_down := _edge_scroll_cue_line_color(active_down) + draw_rect(Rect2(Vector2(view_rect.position.x, view_rect.end.y - strip_down), Vector2(view_rect.size.x, strip_down)), cue_color_down) + draw_line(Vector2(view_rect.position.x + view_rect.size.x * 0.5, view_rect.end.y - 5.0), Vector2(view_rect.position.x + view_rect.size.x * 0.5 - 8.0, view_rect.end.y - 11.0), line_color_down, 2.0) + draw_line(Vector2(view_rect.position.x + view_rect.size.x * 0.5, view_rect.end.y - 5.0), Vector2(view_rect.position.x + view_rect.size.x * 0.5 + 8.0, view_rect.end.y - 11.0), line_color_down, 2.0) + + +func _edge_scroll_active_sides(mouse_position: Vector2, view_rect: Rect2) -> Dictionary: + var velocity := _edge_scroll_velocity_for_position(mouse_position, view_rect) + return { + "left": velocity.x > 0.0, + "right": velocity.x < 0.0, + "up": velocity.y > 0.0, + "down": velocity.y < 0.0 + } + + +func _edge_scroll_cue_strip_width(active: bool) -> float: + return 20.0 if active else 14.0 + + +func _edge_scroll_cue_fill_color(active: bool) -> Color: + return Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.34 if active else 0.18) + + +func _edge_scroll_cue_line_color(active: bool) -> Color: + return Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.62 if active else 0.36) func _draw_minimap() -> void: diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index 22ea728..0770fbe 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -120,7 +120,11 @@ func _check_edge_scroll_velocity_curve(scene, view_rect: Rect2, failures: Array[ "_edge_scroll_pressure", "_edge_scroll_speed_for_pressure", "_edge_scroll_activation_rect", - "_edge_scroll_next_offset" + "_edge_scroll_next_offset", + "_edge_scroll_active_sides", + "_edge_scroll_cue_strip_width", + "_edge_scroll_cue_fill_color", + "_edge_scroll_cue_line_color" ]: if not scene.has_method(method_name): failures.append("missing edge-scroll helper: %s" % method_name) @@ -150,6 +154,26 @@ func _check_edge_scroll_velocity_curve(scene, view_rect: Rect2, failures: Array[ if top_left.length() > max_edge_speed + 0.01: failures.append("corner edge scroll should cap diagonal speed: %.2f > %.2f" % [top_left.length(), max_edge_speed]) + var idle_sides: Dictionary = scene._edge_scroll_active_sides(view_rect.get_center(), view_rect) + if bool(idle_sides.get("left", false)) or bool(idle_sides.get("right", false)) or bool(idle_sides.get("up", false)) or bool(idle_sides.get("down", false)): + failures.append("edge cue sides should stay idle away from the frame: %s" % str(idle_sides)) + var left_active_sides: Dictionary = scene._edge_scroll_active_sides(Vector2(view_rect.position.x + 2.0, view_rect.get_center().y), view_rect) + if not bool(left_active_sides.get("left", false)) or bool(left_active_sides.get("right", false)): + failures.append("left edge cue should activate only the left side: %s" % str(left_active_sides)) + var right_active_sides: Dictionary = scene._edge_scroll_active_sides(Vector2(view_rect.end.x - 2.0, view_rect.get_center().y), view_rect) + if not bool(right_active_sides.get("right", false)) or bool(right_active_sides.get("left", false)): + failures.append("right edge cue should activate only the right side: %s" % str(right_active_sides)) + if float(scene._edge_scroll_cue_strip_width(true)) <= float(scene._edge_scroll_cue_strip_width(false)): + failures.append("active edge cue should draw a wider response strip") + var active_fill: Color = scene._edge_scroll_cue_fill_color(true) + var idle_fill: Color = scene._edge_scroll_cue_fill_color(false) + if active_fill.a <= idle_fill.a: + failures.append("active edge cue fill should be brighter than idle fill") + var active_line: Color = scene._edge_scroll_cue_line_color(true) + var idle_line: Color = scene._edge_scroll_cue_line_color(false) + if active_line.a <= idle_line.a: + failures.append("active edge cue line should be brighter than idle line") + var near_outside_left: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.position - Vector2(4.0, 0.0), view_rect) if near_outside_left.x <= 0.0: failures.append("edge scroll should continue when the mouse slightly crosses the left map edge: %s" % str(near_outside_left)) @@ -199,7 +223,10 @@ func _check_minimap_navigation_contract(failures: Array[String]) -> void: "_minimap_cell_rect", "_minimap_view_rect", "_board_scroll_offset_for_minimap_position", - "_scroll_board_to_minimap_position" + "_scroll_board_to_minimap_position", + "_handle_minimap_mouse_button", + "_handle_minimap_mouse_motion", + "_is_minimap_visible" ]: if not scene.has_method(method_name): failures.append("missing minimap helper: %s" % method_name) @@ -245,6 +272,33 @@ func _check_minimap_navigation_contract(failures: Array[String]) -> void: scene._scroll_board_to_minimap_position(map_rect.get_center()) if scene.board_scroll_offset.distance_squared_to(center_offset) > 0.01: failures.append("minimap scroll action should apply the calculated center offset: %s vs %s" % [str(scene.board_scroll_offset), str(center_offset)]) + + scene.board_scroll_offset = Vector2.ZERO + var drag_start := map_rect.position + Vector2(map_rect.size.x * 0.35, map_rect.size.y * 0.35) + var drag_end := map_rect.end + var press := InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.pressed = true + press.position = drag_start + if not scene._handle_minimap_mouse_button(press): + failures.append("minimap press inside the map body should be consumed") + if not scene.minimap_dragging: + failures.append("minimap press inside the map body should start dragging") + var drag_motion := InputEventMouseMotion.new() + drag_motion.position = drag_end + var expected_drag_offset: Vector2 = scene._board_scroll_offset_for_minimap_position(drag_end) + if not scene._handle_minimap_mouse_motion(drag_motion): + failures.append("minimap drag motion should be consumed while dragging") + if scene.board_scroll_offset.distance_squared_to(expected_drag_offset) > 0.01: + failures.append("minimap drag should keep recentering the tactical viewport: %s vs %s" % [str(scene.board_scroll_offset), str(expected_drag_offset)]) + var release := InputEventMouseButton.new() + release.button_index = MOUSE_BUTTON_LEFT + release.pressed = false + release.position = drag_end + if not scene._handle_minimap_mouse_button(release): + failures.append("minimap release should be consumed after dragging") + if scene.minimap_dragging: + failures.append("minimap release should end dragging") scene.free()