From b01995db004be512ec958c58a600f9a169fa474f Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 19:29:56 +0900 Subject: [PATCH] Show unit facing on battlefield --- docs/ARCHITECTURE.md | 4 +- scripts/scenes/battle_scene.gd | 64 +++++++++++++++++++++++++++++-- tools/smoke_directional_combat.gd | 18 +++++++++ tools/smoke_visual_assets.gd | 12 ++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d041ef4..94c72b3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,7 +39,7 @@ 4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, selectable camp Talk conversations, manual checkpoint menu, and optional pre-battle shop, Armory, Roster, and Formation setup. 5. Player selects a unit. 6. Briefing completion dispatches battle-begin events, including opening dialogue. -7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, class-specific physical attack motion profiles, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, selected/hover unit role, movement, terrain, status, and equipment details, and floating combat text for core action and UI feedback. +7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, add facing-based side/rear damage bonuses, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, class-specific physical attack motion profiles, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, selected/hover unit role, movement, facing, terrain, status, and equipment details, and floating combat text for core action and UI feedback. 8. Player ends turn. 9. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock tactics through the same combat and skill resolvers. 10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. @@ -59,7 +59,7 @@ 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. 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, 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. +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. 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/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 76913dc..4280771 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -3390,6 +3390,7 @@ func _draw_units() -> void: _draw_pixel_unit_sprite(rect, unit, team_color, sprite_modulate, _unit_pixel_facing(unit)) _draw_unit_identity_marks(rect, center, unit, team_color) _draw_unit_class_badge(rect, unit, team_color) + _draw_unit_facing_marker(rect, unit, team_color, sprite_modulate) var hp_ratio := float(unit["hp"]) / float(max(1, int(unit["max_hp"]))) var hp_back := Rect2(rect.position + Vector2(8, TILE_SIZE - 8), Vector2(TILE_SIZE - 16, 5)) @@ -3944,6 +3945,57 @@ func _draw_unit_class_badge(rect: Rect2, unit: Dictionary, team_color: Color) -> draw_string(font, badge_rect.position + Vector2(0, 15), _unit_class_abbrev(unit), HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x, 13, UI_PARCHMENT_TEXT) +func _draw_unit_facing_marker(rect: Rect2, unit: Dictionary, team_color: Color, modulate: Color = Color.WHITE) -> void: + var points := _unit_facing_marker_points(rect, unit) + if points.size() < 3: + return + var fill := _unit_facing_marker_fill_color(unit, team_color) + fill.a *= modulate.a + var shadow_points := _offset_points(points, Vector2(1.2, 1.2)) + draw_colored_polygon(shadow_points, Color(0.020, 0.012, 0.006, 0.64 * modulate.a)) + draw_colored_polygon(points, fill) + draw_polyline(_closed_polyline_points(points), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.88 * modulate.a), 1.3) + + +func _unit_facing_marker_points(rect: Rect2, unit: Dictionary) -> PackedVector2Array: + var facing := _unit_pixel_facing(unit) + var center_y := rect.position.y + rect.size.y * 0.48 + if facing < 0: + return PackedVector2Array([ + Vector2(rect.position.x + 5.0, center_y), + Vector2(rect.position.x + 18.0, center_y - 7.0), + Vector2(rect.position.x + 18.0, center_y + 7.0) + ]) + return PackedVector2Array([ + Vector2(rect.end.x - 5.0, center_y), + Vector2(rect.end.x - 18.0, center_y - 7.0), + Vector2(rect.end.x - 18.0, center_y + 7.0) + ]) + + +func _unit_facing_marker_fill_color(unit: Dictionary, team_color: Color) -> Color: + if _is_generic_enemy_unit(unit): + return Color(ENEMY_COLOR.r, ENEMY_COLOR.g, ENEMY_COLOR.b, 0.84) + var facing_color := team_color.lightened(0.24) + return Color(facing_color.r, facing_color.g, facing_color.b, 0.84) + + +func _offset_points(points: PackedVector2Array, offset: Vector2) -> PackedVector2Array: + var result := PackedVector2Array() + for point in points: + result.append(point + offset) + return result + + +func _closed_polyline_points(points: PackedVector2Array) -> PackedVector2Array: + var result := PackedVector2Array() + for point in points: + result.append(point) + if not points.is_empty(): + result.append(points[0]) + return result + + func _draw_unit_class_emblem(rect: Rect2, center: Vector2, unit: Dictionary, team_color: Color) -> void: var family := _unit_class_family(unit) var class_color := _unit_class_badge_color(unit, team_color) @@ -5239,11 +5291,12 @@ func _format_unit_focus_text(unit: Dictionary, prefix: String) -> String: int(unit.get("int", 0)), int(unit.get("agi", 0)) ]) - lines.append("행군 %d (%s) 타격 %d-%d" % [ + lines.append("행군 %d (%s) 타격 %d-%d 시선 %s" % [ int(unit.get("move", 0)), _format_move_type(str(unit.get("move_type", "foot"))), int(unit.get("min_range", 1)), - int(unit.get("range", 1)) + int(unit.get("range", 1)), + _unit_facing_text(unit) ]) lines.append(_unit_action_status_text(unit)) var ai_text := _unit_ai_behavior_text(unit) @@ -5298,6 +5351,10 @@ func _unit_action_status_text(unit: Dictionary) -> String: return "군령: %s%s" % [_join_strings(actions, ", "), suffix] +func _unit_facing_text(unit: Dictionary) -> String: + return "서쪽" if _unit_pixel_facing(unit) < 0 else "동쪽" + + func _unit_has_usable_item_target(unit_id: String) -> bool: for item_id in state.get_usable_item_ids(): if _item_has_valid_target(unit_id, str(item_id)): @@ -5494,11 +5551,12 @@ func _update_cell_info() -> void: var unit: Dictionary = summary["unit"] if not unit.is_empty(): var control_label := " 호위" if not bool(unit.get("controllable", true)) else "" - text += "\n군기: %s%s %s 품계 %d\n%s %s 병력 %d/%d 기력 %d/%d\n병장: %s" % [ + text += "\n군기: %s%s %s 품계 %d 시선 %s\n%s %s 병력 %d/%d 기력 %d/%d\n병장: %s" % [ unit["name"], control_label, _unit_team_text(str(unit["team"])), unit["level"], + _unit_facing_text(unit), _unit_class_display_name(unit), _unit_role_text(unit), unit["hp"], diff --git a/tools/smoke_directional_combat.gd b/tools/smoke_directional_combat.gd index e839c40..41ac666 100644 --- a/tools/smoke_directional_combat.gd +++ b/tools/smoke_directional_combat.gd @@ -135,6 +135,24 @@ func _check_scene_pixel_facing(failures: Array[String]) -> void: failures.append("pixel facing should use player facing_x") if scene._unit_pixel_facing(enemy) != 1: failures.append("pixel facing should use enemy facing_x") + if scene._unit_facing_text(player) != "서쪽": + failures.append("unit facing text should localize west-facing units") + if scene._unit_facing_text(enemy) != "동쪽": + failures.append("unit facing text should localize east-facing units") + + var marker_rect := Rect2(Vector2(12.0, 20.0), Vector2(scene.TILE_SIZE, scene.TILE_SIZE)) + var left_points: PackedVector2Array = scene._unit_facing_marker_points(marker_rect, player) + var right_points: PackedVector2Array = scene._unit_facing_marker_points(marker_rect, enemy) + if left_points.size() != 3 or right_points.size() != 3: + failures.append("unit facing marker should draw a compact triangular marker") + elif left_points[0].x >= left_points[1].x or right_points[0].x <= right_points[1].x: + failures.append("unit facing marker should point toward the unit facing: left=%s right=%s" % [str(left_points), str(right_points)]) + var closed_points: PackedVector2Array = scene._closed_polyline_points(left_points) + if closed_points.size() != 4 or closed_points[0] != closed_points[3]: + failures.append("unit facing marker outline should close the triangle: %s" % str(closed_points)) + var marker_color: Color = scene._unit_facing_marker_fill_color(player, Color(0.2, 0.4, 0.8, 1.0)) + if marker_color.a <= 0.0: + failures.append("unit facing marker fill should be visible") scene.unit_action_motion_by_unit["cao_cao"] = { "from": Vector2i(4, 3), diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 9ae54c7..faa5779 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3032,6 +3032,8 @@ func _check_hud_focus_text(failures: Array[String]) -> void: failures.append("Cao Cao HUD focus should describe command role: %s" % cao_text) if not cao_text.contains("행군 4 (보병)") or not cao_text.contains("타격 1-1"): failures.append("Cao Cao HUD focus should clarify move/attack affordance: %s" % cao_text) + if not cao_text.contains("시선 동쪽"): + failures.append("Cao Cao HUD focus should expose facing direction: %s" % cao_text) if not cao_text.contains("군령:") or not cao_text.contains("행군") or not cao_text.contains("타격") or not cao_text.contains("책략") or not cao_text.contains("병장") or not cao_text.contains("대기"): failures.append("Cao Cao HUD focus should summarize available actions: %s" % cao_text) if not cao_text.contains("지형 2,7"): @@ -3051,6 +3053,8 @@ func _check_hud_focus_text(failures: Array[String]) -> void: failures.append("Archer HUD focus should describe ranged role: %s" % archer_text) if not archer_text.contains("행군 4 (궁병)") or not archer_text.contains("타격 2-2"): failures.append("Archer HUD focus should clarify ranged attack affordance: %s" % archer_text) + if not archer_text.contains("시선 서쪽"): + failures.append("Enemy archer HUD focus should expose facing direction: %s" % archer_text) if not archer_text.contains("군령: 적군 군기"): failures.append("Enemy archer HUD focus should describe enemy control: %s" % archer_text) if not scene._unit_current_terrain_text(archer).contains("산지"): @@ -3068,6 +3072,14 @@ func _check_hud_focus_text(failures: Array[String]) -> void: var marker_rect: Rect2 = scene._recovery_marker_rect(Vector2i(6, 5)) if not scene._rect_for_cell(Vector2i(6, 5)).encloses(marker_rect): failures.append("Recovery terrain marker should stay inside its tile: %s" % str(marker_rect)) + var facing_tile_rect: Rect2 = scene._rect_for_cell(Vector2i(1, 6)) + var facing_marker_points: PackedVector2Array = scene._unit_facing_marker_points(facing_tile_rect, cao_cao) + var facing_points_inside := facing_marker_points.size() == 3 + for point in facing_marker_points: + if not facing_tile_rect.has_point(point): + facing_points_inside = false + if not facing_points_inside: + failures.append("Unit facing marker should stay anchored inside its tile: %s" % str(facing_marker_points)) _check_unit_class_mark(failures, scene, "cao_cao", "tactic", "책") _check_unit_class_mark(failures, scene, "xiahou_dun", "cavalry", "기") _check_unit_class_mark(failures, scene, "yellow_turban_2", "infantry", "보")