From f13f20939f0ba6792f72fd391350e2c5d03f02af Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 18:53:03 +0900 Subject: [PATCH] Refine large map viewport controls --- docs/ARCHITECTURE.md | 2 ++ scripts/scenes/battle_scene.gd | 19 ++++++++---- tools/smoke_chapter_one_polish.gd | 11 +++++-- tools/smoke_post_move_action_flow.gd | 45 ++++++++++++++++++++++++---- tools/smoke_visual_assets.gd | 6 ++-- 5 files changed, 67 insertions(+), 16 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6fc8403..d5e4687 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,4 +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, and gear, board maps can draw a scenario background texture under translucent terrain overlays, connected road segments, terrain detail patterns, and shoreline/castle/forest/hill edge blends, 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. + 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 82733b8..2d118c7 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -4399,11 +4399,13 @@ func _draw_target_preview_badge() -> void: func _target_preview_badge_rect(cell: Vector2i, size: Vector2 = TARGET_PREVIEW_BADGE_SIZE) -> Rect2: var cell_rect := _rect_for_cell(cell) var position := cell_rect.position + Vector2((TILE_SIZE - size.x) * 0.5, 5.0) - var board := _board_rect() - var min_x := board.position.x + 2.0 - var min_y := board.position.y + 2.0 - var max_x := maxf(min_x, board.end.x - size.x - 2.0) - var max_y := maxf(min_y, board.end.y - size.y - 2.0) + var visible_board := _board_rect().intersection(_map_view_rect()) + if visible_board.size.x <= 0.0 or visible_board.size.y <= 0.0: + visible_board = _board_rect() + var min_x := visible_board.position.x + 2.0 + var min_y := visible_board.position.y + 2.0 + var max_x := maxf(min_x, visible_board.end.x - size.x - 2.0) + var max_y := maxf(min_y, visible_board.end.y - size.y - 2.0) position.x = clampf(position.x, min_x, max_x) position.y = clampf(position.y, min_y, max_y) return Rect2(position, size) @@ -4654,10 +4656,15 @@ func _target_preview_badge_color(kind: String) -> Color: func _cell_from_screen(screen_position: Vector2) -> Vector2i: + if not _map_view_rect().has_point(screen_position): + return Vector2i(-1, -1) var local := screen_position - _board_origin() if local.x < 0 or local.y < 0: return Vector2i(-1, -1) - return Vector2i(int(floor(local.x / TILE_SIZE)), int(floor(local.y / TILE_SIZE))) + var cell := Vector2i(int(floor(local.x / TILE_SIZE)), int(floor(local.y / TILE_SIZE))) + if not state.is_inside(cell): + return Vector2i(-1, -1) + return cell func _rect_for_cell(cell: Vector2i) -> Rect2: diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index ddce6b6..22ea728 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -98,9 +98,16 @@ func _check_edge_scroll_layout_contract(failures: Array[String]) -> void: failures.append("edge scroll clamp should not create positive board offsets") scene.board_scroll_offset = far_offset + var far_visible_cell := Vector2i(scene.state.map_size.x - 1, scene.state.map_size.y - 1) + var far_visible_screen := scene._rect_for_cell(far_visible_cell).position + Vector2(4.0, 4.0) + if scene._cell_from_screen(far_visible_screen) != far_visible_cell: + failures.append("screen-to-cell mapping should stay stable for the visible far edge after scrolling") var first_cell_screen := scene._rect_for_cell(Vector2i(0, 0)).position + Vector2(4.0, 4.0) - if scene._cell_from_screen(first_cell_screen) != Vector2i(0, 0): - failures.append("screen-to-cell mapping should stay stable after scrolling") + if scene._cell_from_screen(first_cell_screen) != Vector2i(-1, -1): + failures.append("hidden map cells should not accept board clicks after scrolling") + var side_panel_screen := Vector2(view_rect.end.x + 8.0, view_rect.position.y + 24.0) + if scene._cell_from_screen(side_panel_screen) != Vector2i(-1, -1): + failures.append("screen-to-cell mapping should ignore clicks outside the visible map view") _check_edge_scroll_velocity_curve(scene, view_rect, failures) scene.free() diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd index 94b5d47..ae7d6c7 100644 --- a/tools/smoke_post_move_action_flow.gd +++ b/tools/smoke_post_move_action_flow.gd @@ -9,6 +9,7 @@ func _init() -> void: _check_deferred_move_events(failures) _check_scene_post_move_menu_flow(failures) _check_scene_post_move_edge_positioning(failures) + _check_scene_post_move_scrolled_view_positioning(failures) _check_scene_post_move_text_fit(failures) _check_scene_post_move_tactic_picker_flow(failures) _check_scene_post_move_item_picker_flow(failures) @@ -170,6 +171,39 @@ func _check_scene_post_move_edge_positioning(failures: Array[String]) -> void: scene.free() +func _check_scene_post_move_scrolled_view_positioning(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 scrolled menu positioning") + 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.board_scroll_offset = scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) + + var edge_cell := Vector2i(scene.state.map_size.x - 1, scene.state.map_size.y - 1) + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + cao_cao["pos"] = edge_cell + cao_cao["moved"] = true + cao_cao["acted"] = false + scene.state.select_unit("cao_cao") + scene.pending_move_unit_id = "cao_cao" + scene.pending_move_from_cell = Vector2i(edge_cell.x - 1, edge_cell.y) + scene.pending_move_to_cell = edge_cell + scene._show_post_move_menu() + + if scene.post_move_menu == null or not scene.post_move_menu.visible: + failures.append("scrolled post-move action menu should be visible") + else: + _check_local_panel_position(failures, scene, scene.post_move_menu, edge_cell, "scrolled post-move action menu") + + scene.free() + + func _check_scene_post_move_text_fit(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() @@ -577,17 +611,18 @@ func _check_scene_post_move_item_picker_flow(failures: Array[String]) -> void: func _check_local_panel_position(failures: Array[String], scene, panel: Control, cell: Vector2i, label: String) -> void: var padding := BattleSceneScript.LOCAL_COMMAND_PANEL_BOARD_PADDING - var board_position := BattleSceneScript.BOARD_OFFSET + Vector2(padding, padding) - var board_size := Vector2(scene.state.map_size.x, scene.state.map_size.y) * BattleSceneScript.TILE_SIZE - Vector2(padding * 2.0, padding * 2.0) - var board_rect := Rect2(board_position, board_size) + var board_rect: Rect2 = scene._board_rect().intersection(scene._map_view_rect()) + if board_rect.size.x <= 0.0 or board_rect.size.y <= 0.0: + board_rect = scene._board_rect() + board_rect = board_rect.grow(-padding) var panel_rect := Rect2(panel.position, panel.size) var cell_rect: Rect2 = scene._rect_for_cell(cell).grow(2.0) if panel_rect.size.x <= 0.0 or panel_rect.size.y <= 0.0: failures.append("%s should have a stable visible size" % label) if panel_rect.position.x < board_rect.position.x or panel_rect.position.y < board_rect.position.y: - failures.append("%s should stay inside the board minimum: %s" % [label, str(panel_rect)]) + failures.append("%s should stay inside the visible board minimum: %s" % [label, str(panel_rect)]) if panel_rect.end.x > board_rect.end.x or panel_rect.end.y > board_rect.end.y: - failures.append("%s should stay inside the board maximum: %s" % [label, str(panel_rect)]) + failures.append("%s should stay inside the visible board maximum: %s" % [label, str(panel_rect)]) if panel_rect.intersects(cell_rect): failures.append("%s should not cover the moved unit cell: %s over %s" % [label, str(panel_rect), str(cell_rect)]) diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 58d7cb3..0578a25 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3585,12 +3585,12 @@ func _check_hover_intent_badges(failures: Array[String]) -> void: failures.append("COUNTER badge should use a distinct color") if scene._target_preview_badge_color("danger") == default_color: failures.append("DANGER badge should use a distinct color") - var board_rect: Rect2 = scene._board_rect() + var board_rect: Rect2 = scene._board_rect().intersection(scene._map_view_rect()) var edge_badge_rect: Rect2 = scene._target_preview_badge_rect(Vector2i(13, 9)) if edge_badge_rect.position.x < board_rect.position.x or edge_badge_rect.position.y < board_rect.position.y: - failures.append("edge target preview badge should stay inside board minimum: %s / %s" % [str(edge_badge_rect), str(board_rect)]) + failures.append("edge target preview badge should stay inside visible board minimum: %s / %s" % [str(edge_badge_rect), str(board_rect)]) if edge_badge_rect.end.x > board_rect.end.x or edge_badge_rect.end.y > board_rect.end.y: - failures.append("edge target preview badge should stay inside board maximum: %s / %s" % [str(edge_badge_rect), str(board_rect)]) + failures.append("edge target preview badge should stay inside visible board maximum: %s / %s" % [str(edge_badge_rect), str(board_rect)]) if edge_badge_rect.size.x <= BattleSceneScript.TILE_SIZE: failures.append("target preview badge should remain wide enough for Korean tactical text") scene.free()