diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 61e25f0..cedb1b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -59,8 +59,8 @@ Joined officers gate whether `requires_joined` player deployments are loaded at Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. Briefings preview first-clear rewards, can expose `briefing.camp_conversations` through a selectable Talk menu with `briefing.camp_dialogue` as a fallback, can filter those conversations by saved campaign flags, can claim saved one-time camp conversation supply effects through `CampaignState` and refresh the loaded battle inventory copy, and show completed replay rewards as already claimed. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps, and `shop.merchant` lines are presentation-only camp flavor. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Camp conversation supply claims are also campaign transactions: they save a per-scenario conversation claim ledger and inventory immediately, then refresh the already-loaded battle inventory. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. Rewarding movement events that grant items or gold can expose optional battlefield side markers and Korean tile-info labels through `when.label`, without being treated as victory objectives. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes and pickup events fire on entry rather than from units already standing on a marker; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, event gold pickups, save writes, current-scenario advancement, finite-stock purchases, or camp conversation supply claims; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. -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, facing, 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, compact recovery markers for healing terrain, compact side-event markers for visible item/gold pickups, and move-attack target badges for enemies that can be struck after movement, board units render class sprite tokens or generic-enemy transparent class cutouts, team rings, class badges, facing markers, generic-enemy marks, officer pins, low-HP warning rings, HP bar color states, active support/debuff/poison/seal/snare/disarm status pips, and a scene-clocked idle stance whose intensity reacts to selected, acted, and low-HP unit state, 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. +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, facing, 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, compact recovery markers for healing terrain, compact side-event markers for visible item/gold pickups, and move-attack target badges for enemies that can be struck after movement, board units render class sprite tokens or generic-enemy transparent class cutouts, team rings, class badges, facing markers, generic-enemy marks, officer pins, low-HP warning rings, HP bar color states, active support/debuff/poison/seal/snare/disarm status pips, and a scene-clocked idle stance whose intensity reacts to selected, acted, and low-HP unit state, item buttons can show optional cached item icons, hover previews are rendered as target badges from existing `BattleState` forecast APIs and include compact side/rear attack bonuses when facing matters, 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, 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. +Large tactical maps are presented through a fixed visible map window rather than by shrinking the whole board, including the opening chapter-one battle. `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 only when the board actually exceeds the visible map view; clicking or dragging the minimap recenters the tactical viewport, while compact maps keep the lower-right tiles unobstructed and 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 25c91ad..a40b544 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -3523,10 +3523,12 @@ func _attack_target_marker_entries() -> Array[Dictionary]: continue var hit_chance := int(preview.get("hit_chance", 100)) var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0)) + var directional_text := _format_directional_badge_suffix(preview) + var marker_text := "%s%s" % [result_text, directional_text] if not directional_text.is_empty() else "%s %d%%" % [result_text, hit_chance] result.append({ "cell": cell, "target_id": str(target.get("id", "")), - "text": "%s %d%%" % [result_text, hit_chance], + "text": marker_text, "kind": _attack_preview_badge_kind(preview) }) if not basic_attack_targeting and not _has_pending_move(): @@ -4700,8 +4702,32 @@ func _attack_target_preview_badge(selected: Dictionary, target: Dictionary) -> D var hit_chance := int(preview.get("hit_chance", 100)) var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0)) + var directional_text := _format_directional_badge_suffix(preview) var counter_text := _format_attack_counter_badge_suffix(preview) - return _make_target_preview_badge("%s %d%%%s" % [result_text, hit_chance, counter_text], _attack_preview_badge_kind(preview)) + return _make_target_preview_badge("%s%s %d%%%s" % [result_text, directional_text, hit_chance, counter_text], _attack_preview_badge_kind(preview)) + + +func _format_directional_badge_suffix(preview: Dictionary) -> String: + var bonus := int(preview.get("directional_bonus", 0)) + if bonus <= 0: + return "" + var label := _compact_directional_badge_label(str(preview.get("directional_attack", ""))) + if label.is_empty(): + label = _compact_directional_badge_label(str(preview.get("directional_label", ""))) + if label.is_empty(): + return "" + return " %s+%d" % [label, bonus] + + +func _compact_directional_badge_label(value: String) -> String: + var normalized := value.strip_edges().to_lower() + if normalized == BattleState.DIRECTIONAL_BACK_ATTACK or value.contains("후"): + return "후" + if normalized == BattleState.DIRECTIONAL_SIDE_ATTACK or value.contains("측"): + return "측" + if normalized == BattleState.DIRECTIONAL_FRONT_ATTACK or value.contains("정"): + return "정" + return "" func _format_attack_counter_badge_suffix(preview: Dictionary) -> String: @@ -4738,13 +4764,38 @@ func _auto_attack_target_preview_badge_for_origin(selected: Dictionary, target: var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1)) if not state.is_inside(attack_origin) or not state.is_inside(target_cell): return {} - var candidate_attacker := selected.duplicate(true) - candidate_attacker["pos"] = attack_origin + var candidate_attacker := _auto_attack_candidate_after_move(selected, target_cell, attack_origin) var damage := state.calculate_damage(candidate_attacker, target) var hit_chance := state.calculate_hit_chance(candidate_attacker, target) var result_text := "행공 퇴각" if int(target.get("hp", 0)) - damage <= 0 else "행공 -%d" % damage + var directional_text := _format_directional_badge_suffix_for_units(candidate_attacker, target) var counter_text := _format_auto_attack_counter_badge_suffix(candidate_attacker, target, damage) - return _make_target_preview_badge_at("%s %d%%%s" % [result_text, hit_chance, counter_text], _auto_attack_preview_badge_kind(candidate_attacker, target, damage), target_cell) + return _make_target_preview_badge_at("%s%s %d%%%s" % [result_text, directional_text, hit_chance, counter_text], _auto_attack_preview_badge_kind(candidate_attacker, target, damage), target_cell) + + +func _auto_attack_candidate_after_move(selected: Dictionary, target_cell: Vector2i, attack_origin: Vector2i) -> Dictionary: + var candidate_attacker := selected.duplicate(true) + candidate_attacker["pos"] = attack_origin + _face_unit_snapshot_toward_cell(candidate_attacker, target_cell) + return candidate_attacker + + +func _face_unit_snapshot_toward_cell(unit: Dictionary, target_cell: Vector2i) -> void: + if unit.is_empty() or not unit.has("pos"): + return + var from_cell: Vector2i = unit.get("pos", target_cell) + if from_cell.x == target_cell.x: + return + unit["facing_x"] = -1 if target_cell.x < from_cell.x else 1 + + +func _format_directional_badge_suffix_for_units(attacker: Dictionary, target: Dictionary) -> String: + var directional_info := state.get_directional_attack_info(attacker, target) + return _format_directional_badge_suffix({ + "directional_attack": str(directional_info.get("kind", BattleState.DIRECTIONAL_FRONT_ATTACK)), + "directional_bonus": int(directional_info.get("bonus", 0)), + "directional_label": str(directional_info.get("label", "")) + }) func _format_auto_attack_counter_badge_suffix(candidate_attacker: Dictionary, target: Dictionary, damage: int) -> String: @@ -5239,7 +5290,19 @@ func _has_active_battle_presentation_sequence() -> bool: func _is_minimap_visible() -> bool: - return battle_started and state.map_size.x > 0 and state.map_size.y > 0 and state.battle_status == BattleState.STATUS_ACTIVE + return ( + battle_started + and state.map_size.x > 0 + and state.map_size.y > 0 + and state.battle_status == BattleState.STATUS_ACTIVE + and _map_has_scrollable_area() + ) + + +func _map_has_scrollable_area() -> bool: + var board_size := _board_size() + var view_size := _map_view_rect().size + return board_size.x > view_size.x + 0.5 or board_size.y > view_size.y + 0.5 func _handle_minimap_mouse_button(event: InputEventMouseButton) -> bool: diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index e895427..d1ca4b1 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -73,8 +73,8 @@ func _check_chapter_one_scenarios_have_visual_grounding(failures: Array[String]) func _check_edge_scroll_layout_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() - if not scene.state.load_battle("res://data/scenarios/009_xiapi_siege.json"): - failures.append("could not load large chapter one map for edge scrolling") + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening chapter one map for edge scrolling") scene.free() return @@ -82,8 +82,8 @@ func _check_edge_scroll_layout_contract(failures: Array[String]) -> void: var board_size := scene._board_size() if view_rect.size.x < 160.0 or view_rect.size.y < 160.0: failures.append("map view rect should keep a usable minimum size") - if board_size.x <= view_rect.size.x and board_size.y <= view_rect.size.y: - failures.append("chapter one should include at least one map large enough to exercise edge scrolling") + if board_size.x <= view_rect.size.x or board_size.y <= view_rect.size.y: + failures.append("opening chapter one map should be larger than the visible map window: board=%s view=%s" % [str(board_size), str(view_rect.size)]) scene._reset_board_scroll() if scene.board_scroll_offset.x > 0.0 or scene.board_scroll_offset.y > 0.0: @@ -211,8 +211,8 @@ func _check_edge_scroll_velocity_curve(scene, view_rect: Rect2, failures: Array[ func _check_minimap_navigation_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() - if not scene.state.load_battle("res://data/scenarios/009_xiapi_siege.json"): - failures.append("could not load large chapter one map for minimap navigation") + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening chapter one map for minimap navigation") scene.free() return scene.battle_started = true @@ -226,12 +226,17 @@ func _check_minimap_navigation_contract(failures: Array[String]) -> void: "_scroll_board_to_minimap_position", "_handle_minimap_mouse_button", "_handle_minimap_mouse_motion", - "_is_minimap_visible" + "_is_minimap_visible", + "_map_has_scrollable_area" ]: if not scene.has_method(method_name): failures.append("missing minimap helper: %s" % method_name) var view_rect := scene._map_view_rect() + if not scene._map_has_scrollable_area(): + failures.append("opening map should require a scrollable minimap viewport") + if not scene._is_minimap_visible(): + failures.append("minimap should be visible on the opening large map") var minimap_rect: Rect2 = scene._minimap_rect() if minimap_rect.size.x < 150.0 or minimap_rect.size.y < 110.0: failures.append("minimap should reserve a usable tactical map plate: %s" % str(minimap_rect)) @@ -301,11 +306,29 @@ func _check_minimap_navigation_contract(failures: Array[String]) -> void: failures.append("minimap release should end dragging") scene.free() + var small_scene = BattleSceneScript.new() + if not small_scene.state.load_battle("res://data/scenarios/002_sishui_gate.json"): + failures.append("could not load compact chapter one map for minimap hiding") + small_scene.free() + return + small_scene.battle_started = true + if small_scene._map_has_scrollable_area(): + failures.append("compact maps should not require minimap scrolling") + if small_scene._is_minimap_visible(): + failures.append("minimap should stay hidden on compact maps") + var compact_press := InputEventMouseButton.new() + compact_press.button_index = MOUSE_BUTTON_LEFT + compact_press.pressed = true + compact_press.position = small_scene._map_view_rect().end - Vector2(18.0, 18.0) + if small_scene._handle_minimap_mouse_button(compact_press): + failures.append("hidden minimap should not consume lower-right board clicks on compact maps") + small_scene.free() + func _check_map_focus_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() - if not scene.state.load_battle("res://data/scenarios/009_xiapi_siege.json"): - failures.append("could not load large chapter one map for map focus") + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening chapter one map for map focus") scene.free() return scene.battle_started = true diff --git a/tools/smoke_directional_combat.gd b/tools/smoke_directional_combat.gd index 41ac666..40be280 100644 --- a/tools/smoke_directional_combat.gd +++ b/tools/smoke_directional_combat.gd @@ -10,6 +10,7 @@ func _init() -> void: _check_attack_updates_facing(failures) _check_move_cancel_restores_facing(failures) _check_scene_pixel_facing(failures) + _check_scene_directional_attack_badges(failures) if failures.is_empty(): print("directional combat smoke ok") @@ -163,6 +164,74 @@ func _check_scene_pixel_facing(failures: Array[String]) -> void: scene.free() +func _check_scene_directional_attack_badges(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for directional attack badges") + scene.free() + return + var attacker: Dictionary = scene.state.get_unit("cao_cao") + var target: Dictionary = scene.state.get_unit("yellow_turban_1") + if attacker.is_empty() or target.is_empty(): + failures.append("missing units for directional attack badge") + scene.free() + return + for unit in scene.state.units: + if str(unit.get("id", "")) != "cao_cao" and str(unit.get("id", "")) != "yellow_turban_1": + unit["alive"] = false + _configure_duel_units(attacker, target) + attacker["move"] = 4 + attacker["pos"] = Vector2i(4, 9) + target["pos"] = Vector2i(5, 9) + target["facing_x"] = 1 + target["controllable"] = false + scene.battle_started = true + if not scene.state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for directional attack badge") + scene.free() + return + scene._refresh_ranges() + scene.hover_cell = Vector2i(5, 9) + var badge := scene._target_preview_badge() + if not str(badge.get("text", "")).contains("후+%d" % BattleStateScript.DIRECTIONAL_BACK_DAMAGE_BONUS): + failures.append("rear attack hover badge should expose compact rear bonus: %s" % str(badge)) + var marker_text := "" + for marker in scene._attack_target_marker_entries(): + if str(marker.get("target_id", "")) == "yellow_turban_1": + marker_text = str(marker.get("text", "")) + if not marker_text.contains("후+%d" % BattleStateScript.DIRECTIONAL_BACK_DAMAGE_BONUS): + failures.append("rear attack target marker should expose compact rear bonus: %s" % marker_text) + + attacker["pos"] = Vector2i(5, 8) + scene._refresh_ranges() + scene.hover_cell = Vector2i(5, 9) + var side_badge: Dictionary = scene._target_preview_badge() + if not str(side_badge.get("text", "")).contains("측+%d" % BattleStateScript.DIRECTIONAL_SIDE_DAMAGE_BONUS): + failures.append("side attack hover badge should expose compact side bonus: %s" % str(side_badge)) + + attacker["pos"] = Vector2i(2, 9) + attacker["facing_x"] = -1 + target["pos"] = Vector2i(5, 9) + target["facing_x"] = 1 + scene._refresh_ranges() + scene.hover_cell = Vector2i(5, 9) + var auto_badge: Dictionary = scene._target_preview_badge() + if not str(auto_badge.get("text", "")).contains("행공"): + failures.append("auto-move attack hover badge should be used for reachable distant enemy: %s" % str(auto_badge)) + if not str(auto_badge.get("text", "")).contains("후+%d" % BattleStateScript.DIRECTIONAL_BACK_DAMAGE_BONUS): + failures.append("auto-move attack hover badge should expose compact rear bonus: %s" % str(auto_badge)) + + if scene._compact_directional_badge_label(BattleStateScript.DIRECTIONAL_SIDE_ATTACK) != "측": + failures.append("directional badge helper should compact side attacks") + if scene._format_directional_badge_suffix({ + "directional_attack": BattleStateScript.DIRECTIONAL_FRONT_ATTACK, + "directional_bonus": 0, + "directional_label": "정면" + }) != "": + failures.append("front/no-bonus directional badge suffix should stay empty") + scene.free() + + func _configure_duel_units(attacker: Dictionary, target: Dictionary) -> void: attacker["atk"] = 30 attacker["agi"] = 50