From 6d95f13794bb5771a2172ecbad1ae82d5a8ff204 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 19:42:00 +0900 Subject: [PATCH] Show side event markers --- data/scenarios/001_yellow_turbans.json | 2 + docs/ARCHITECTURE.md | 4 +- docs/DATA_MODEL.md | 4 +- scripts/core/battle_state.gd | 102 +++++++++++++++++++++++++ scripts/scenes/battle_scene.gd | 60 ++++++++++++++- tools/smoke_objective_progress.gd | 37 +++++++++ tools/smoke_visual_assets.gd | 15 ++++ 7 files changed, 220 insertions(+), 4 deletions(-) diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index f7505d0..b243c77 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -602,6 +602,7 @@ "when": { "type": "unit_reaches_tile", "team": "player", + "label": "북숲 보급고", "pos": [ 4, 2 @@ -641,6 +642,7 @@ "when": { "type": "unit_reaches_tile", "team": "player", + "label": "마을 보급", "cells": [ [ 6, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 94c72b3..9921507 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -57,9 +57,9 @@ Joined officers gate whether `requires_joined` player deployments are loaded at all. Rewards and post-battle choices can add or remove joined officers without deleting their saved roster snapshot, so a later rejoin can preserve progression. When a save loads, `CampaignState` scans completed scenarios in campaign order and reapplies only officer join/leave transitions from rewards and the saved `applied_post_battle_choices` ledger, allowing added recruitment rewards to reach older saves without replaying gold or items. Old saves without the ledger can backfill a choice id only when exactly one completed scenario choice matches saved flags. Roster entries are keyed by `officer_id` when available, so future scenarios can deploy the same joined officer under different scenario-specific `unit_id` values. Saved progression overlays final battle stats for now; a later equipment refactor should split base stats from equipment bonuses before adding item leveling or stat recalculation. New battles currently start player units healed to their saved maximum HP/MP. Skill access is carried in roster snapshots, with class defaults still applied for old saves that do not include skills. Promoted class ids are saved in the roster, and the next battle rehydrates class-derived movement, growth, skills, and range from the saved class id before equipment range is recalculated. -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. 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. +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, and compact recovery markers for healing terrain, 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, 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. +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, and compact side-event markers for visible item/gold pickups, 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, 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, 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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 683a95e..e85ae03 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -613,7 +613,7 @@ Movement-triggered pickups can use `grant_item` for one item or `grant_items` fo { "id": "northern_woods_cache", "once": true, - "when": { "type": "unit_reaches_tile", "team": "player", "pos": [3, 2] }, + "when": { "type": "unit_reaches_tile", "team": "player", "label": "북숲 보급고", "pos": [3, 2] }, "actions": [ { "type": "log", "text": "The northern woods cache is recovered." }, { "type": "grant_items", "items": ["bean", { "item_id": "antidote", "count": 2 }] } @@ -621,6 +621,8 @@ Movement-triggered pickups can use `grant_item` for one item or `grant_items` fo } ``` +Rewarding `unit_reaches_tile` events are also exposed as optional side-event markers on the battlefield when they are not already objective cells. Use `when.label` to keep the map marker and tile-info text readable in Korean; otherwise the marker falls back to a short supply or gold label derived from its reward action. + Movement-triggered gold caches can use `grant_gold` with a positive `amount`. The gold is shown immediately through log and floating feedback, but it stays battle-only until victory commits the battle result. ```json diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index d3c9469..39f576e 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -1749,6 +1749,67 @@ func get_objective_cell_label(cell: Vector2i) -> String: return _objective_cell_label_for_condition(battle_conditions.get("victory", {}), cell) +func get_event_markers() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var objective_cells := get_objective_cells() + for event in battle_events: + var event_id := str(event.get("id", "")) + if bool(event.get("once", false)) and fired_event_ids.has(event_id): + continue + var when: Dictionary = event.get("when", {}) + if str(when.get("type", "")) != "unit_reaches_tile": + continue + if not _event_gate_open(when) or not _campaign_flags_match(when.get("campaign_flags", {})): + continue + if not _event_has_reward_action(event): + continue + var cells: Array[Vector2i] = [] + for cell in _condition_cells(when): + if not objective_cells.has(cell) and not cells.has(cell): + cells.append(cell) + if cells.is_empty(): + continue + result.append({ + "id": event_id, + "label": _event_marker_label(event), + "kind": _event_marker_kind(event), + "cells": cells + }) + return result + + +func get_event_marker_cells() -> Array[Vector2i]: + var result: Array[Vector2i] = [] + for marker in get_event_markers(): + var cells = marker.get("cells", []) + if typeof(cells) != TYPE_ARRAY: + continue + for cell in cells: + if typeof(cell) == TYPE_VECTOR2I and not result.has(cell): + result.append(cell) + return result + + +func get_event_marker_label(cell: Vector2i) -> String: + if not is_inside(cell): + return "" + for marker in get_event_markers(): + var cells = marker.get("cells", []) + if typeof(cells) == TYPE_ARRAY and cells.has(cell): + return str(marker.get("label", "전장 표식")) + return "" + + +func get_event_marker_kind(cell: Vector2i) -> String: + if not is_inside(cell): + return "" + for marker in get_event_markers(): + var cells = marker.get("cells", []) + if typeof(cells) == TYPE_ARRAY and cells.has(cell): + return str(marker.get("kind", "event")) + return "" + + func get_objective_progress_lines(include_defeat_risks := false, include_turn_limit := true) -> Array[String]: var result: Array[String] = [] _append_condition_progress_lines(battle_conditions.get("victory", {}), result, "victory") @@ -4260,6 +4321,47 @@ func _objective_cell_label_for_after_event(event_id: String, cell: Vector2i) -> return "" +func _event_has_reward_action(event: Dictionary) -> bool: + for action in event.get("actions", []): + if typeof(action) != TYPE_DICTIONARY: + continue + var action_type := str((action as Dictionary).get("type", "")) + if action_type == "grant_item" or action_type == "grant_items" or action_type == "grant_gold": + return true + return false + + +func _event_marker_label(event: Dictionary) -> String: + var when: Dictionary = event.get("when", {}) + var label := _condition_destination_label(when) + if label != "전장 표식": + return label + var kind := _event_marker_kind(event) + if kind == "gold": + return "군자금" + if kind == "supply": + return "보급" + return "전장 표식" + + +func _event_marker_kind(event: Dictionary) -> String: + var has_item := false + var has_gold := false + for action in event.get("actions", []): + if typeof(action) != TYPE_DICTIONARY: + continue + var action_type := str((action as Dictionary).get("type", "")) + if action_type == "grant_item" or action_type == "grant_items": + has_item = true + elif action_type == "grant_gold": + has_gold = true + if has_gold and not has_item: + return "gold" + if has_item: + return "supply" + return "event" + + func _find_turn_limit(condition_group) -> int: if typeof(condition_group) == TYPE_ARRAY: var best_limit := 0 diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 4280771..28d380e 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -505,6 +505,7 @@ func _draw() -> void: _draw_recovery_terrain_markers(_visible_cell_bounds()) _draw_units() _draw_objective_foreground_markers() + _draw_event_foreground_markers() _draw_target_selection_markers() _draw_attack_effects() _draw_target_preview_badge() @@ -3275,7 +3276,7 @@ func _draw_objective_seal_marker(rect: Rect2) -> void: func _draw_objective_foreground_markers() -> void: var font := _draw_ui_font(true) for cell in state.get_objective_cells(): - if not state.is_inside(cell): + if not state.is_inside(cell) or not _cell_is_in_visible_bounds(cell): continue var rect := _rect_for_cell(cell) _draw_objective_corner_brackets(rect) @@ -3285,6 +3286,58 @@ func _draw_objective_foreground_markers() -> void: _draw_ink_text(font, label_rect.position + Vector2(0, 12), "목", HORIZONTAL_ALIGNMENT_CENTER, label_rect.size.x, 11, UI_PARCHMENT_TEXT) +func _draw_event_foreground_markers() -> void: + var font := _draw_ui_font(true) + for marker in state.get_event_markers(): + var cells = marker.get("cells", []) + if typeof(cells) != TYPE_ARRAY: + continue + for cell in cells: + if typeof(cell) != TYPE_VECTOR2I or not state.is_inside(cell) or not _cell_is_in_visible_bounds(cell): + continue + _draw_event_foreground_marker(_rect_for_cell(cell), str(marker.get("label", "보급")), str(marker.get("kind", "event")), font) + + +func _draw_event_foreground_marker(rect: Rect2, label: String, kind: String, font: Font) -> void: + var color := _event_marker_color(kind) + var marker_rect := _event_marker_rect(rect) + draw_rect(marker_rect, Color(0.035, 0.022, 0.012, 0.76)) + draw_rect(marker_rect.grow(-2.0), Color(color.r, color.g, color.b, 0.72)) + draw_rect(marker_rect, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.88), false, 1.2) + var text := _event_marker_abbrev(label, kind) + _draw_ink_text(font, marker_rect.position + Vector2(0, 12), text, HORIZONTAL_ALIGNMENT_CENTER, marker_rect.size.x, 11, UI_PARCHMENT_TEXT) + var knot := marker_rect.get_center() + Vector2(0.0, -8.0) + draw_line(knot + Vector2(-5.0, 0.0), knot + Vector2(5.0, 0.0), Color(0.10, 0.05, 0.02, 0.70), 1.2) + draw_line(knot + Vector2(0.0, -4.0), knot + Vector2(0.0, 4.0), Color(0.10, 0.05, 0.02, 0.56), 1.0) + + +func _event_marker_rect(cell_rect: Rect2) -> Rect2: + return Rect2(cell_rect.position + Vector2(4.0, 26.0), Vector2(23.0, 17.0)) + + +func _event_marker_color(kind: String) -> Color: + if kind == "gold": + return Color(0.92, 0.62, 0.18, 1.0) + if kind == "supply": + return Color(0.38, 0.62, 0.34, 1.0) + return Color(UI_MUTED_JADE.r, UI_MUTED_JADE.g, UI_MUTED_JADE.b, 1.0) + + +func _event_marker_abbrev(label: String, kind: String) -> String: + if kind == "gold": + return "금" + var text := label.strip_edges() + if text.is_empty(): + return "보" if kind == "supply" else "표" + if text.contains("마을"): + return "마" + if text.contains("숲"): + return "숲" + if text.contains("보급"): + return "보" + return text.substr(0, 1) + + func _draw_objective_corner_brackets(rect: Rect2) -> void: var bracket := 12.0 var inset := 4.0 @@ -3380,6 +3433,8 @@ func _draw_units() -> void: continue var rect := _visual_rect_for_unit(unit) + if not rect.intersects(_map_view_rect().grow(TILE_SIZE)): + continue var center := rect.position + rect.size * 0.5 var team_color := PLAYER_COLOR if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR var acted := bool(unit.get("acted", false)) @@ -5543,6 +5598,9 @@ func _update_cell_info() -> void: var objective_cell_label := state.get_objective_cell_label(cell) if not objective_cell_label.is_empty(): text += "\n목표: %s" % objective_cell_label + var event_marker_label := state.get_event_marker_label(cell) + if not event_marker_label.is_empty(): + text += "\n표식: %s" % event_marker_label if show_threat_overlay: var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY) if not threat_names.is_empty(): diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd index 8310624..bf82e2f 100644 --- a/tools/smoke_objective_progress.gd +++ b/tools/smoke_objective_progress.gd @@ -172,6 +172,7 @@ func _init() -> void: _check_event_gated_objective_markers(failures) _check_event_gated_multi_cell_objective_markers(failures) _check_turn_gated_objective_does_not_create_marker(failures) + _check_opening_battle_side_event_markers(failures) _check_opening_battle_requires_castle_capture(failures) _check_opening_battle_capture_events(failures) _check_sishui_gate_extended_pressure(failures) @@ -327,6 +328,42 @@ func _check_turn_gated_objective_does_not_create_marker(failures: Array[String]) failures.append("turn-gated objective should not create map markers: %s" % str(marker_cells)) +func _check_opening_battle_side_event_markers(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("opening side event marker smoke could not load scenario") + return + + var cache_cell := Vector2i(4, 2) + var village_cell := Vector2i(6, 5) + var initial_cells := state.get_event_marker_cells() + if not initial_cells.has(cache_cell): + failures.append("opening battle should mark the northern woods supply cache: %s" % str(initial_cells)) + if not initial_cells.has(village_cell): + failures.append("opening battle should mark village supply side event cells: %s" % str(initial_cells)) + if state.get_event_marker_label(cache_cell) != "북숲 보급고": + failures.append("northern woods cache marker should expose Korean label: %s" % state.get_event_marker_label(cache_cell)) + if state.get_event_marker_kind(cache_cell) != "supply": + failures.append("northern woods cache marker should be a supply marker") + if state.get_event_marker_label(village_cell) != "마을 보급": + failures.append("village supply marker should expose Korean label: %s" % state.get_event_marker_label(village_cell)) + if state.get_event_marker_kind(village_cell) != "supply": + failures.append("village supply marker should be a supply marker") + + var cao_cao: Dictionary = state.get_unit("cao_cao") + cao_cao["pos"] = cache_cell + state._run_events("unit_reaches_tile", BattleStateScript.TEAM_PLAYER, 1, cao_cao) + if state.get_event_marker_cells().has(cache_cell): + failures.append("northern woods supply marker should clear after pickup") + if int(state.get_inventory_snapshot().get("bean", 0)) < 1: + failures.append("northern woods supply marker event should grant a Bean") + + cao_cao["pos"] = village_cell + state._run_events("unit_reaches_tile", BattleStateScript.TEAM_PLAYER, 2, cao_cao) + if state.get_event_marker_cells().has(village_cell): + failures.append("village supply marker should clear after securing the village") + + func _check_opening_battle_requires_castle_capture(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index faa5779..f290dad 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3696,8 +3696,23 @@ func _check_terrain_and_unit_presentation(failures: Array[String]) -> void: scene._update_cell_info() if scene.cell_info_label == null or not scene.cell_info_label.text.contains("목표: 성채 장악") or not scene.cell_info_label.text.contains("회복 +8"): failures.append("castle objective cell info should expose capture label and healing: %s" % scene.cell_info_label.text) + scene.hover_cell = Vector2i(4, 2) + scene._update_cell_info() + if scene.cell_info_label == null or not scene.cell_info_label.text.contains("표식: 북숲 보급고"): + failures.append("side event marker cell info should expose the supply label: %s" % scene.cell_info_label.text) scene.cell_info_label.free() scene.cell_info_label = null + var event_marker_rect: Rect2 = scene._event_marker_rect(scene._rect_for_cell(Vector2i(4, 2))) + if not scene._rect_for_cell(Vector2i(4, 2)).encloses(event_marker_rect): + failures.append("Side event marker should stay inside its tile: %s" % str(event_marker_rect)) + if scene._event_marker_abbrev("북숲 보급고", "supply") != "숲": + failures.append("Northern woods marker should use a compact forest glyph") + if scene._event_marker_abbrev("마을 보급", "supply") != "마": + failures.append("Village supply marker should use a compact village glyph") + if scene._event_marker_abbrev("", "supply") != "보": + failures.append("Supply marker fallback should stay concise") + if scene._event_marker_abbrev("군자금", "gold") != "금": + failures.append("Gold marker should use a compact gold glyph") var background_fill: Color = scene._terrain_fill_color(Vector2i(0, 0), "G", true) var fallback_fill: Color = scene._terrain_fill_color(Vector2i(0, 0), "G", false) if background_fill.a >= 0.20: