From 2816c0764784315d72a9c764c9eabfd09e3e39b7 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 17:47:18 +0900 Subject: [PATCH] Improve battlefield terrain presentation --- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/ROADMAP.md | 2 +- scripts/scenes/battle_scene.gd | 195 +++++++++++++++++++++++++++++++-- tools/smoke_visual_assets.gd | 60 ++++++++++ 5 files changed, 250 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 68fbbf3..5255f23 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Floating combat text for damage, recovery, misses, support effects, poison ticks, action locks, level-ups, and promotions. - Short unit slide animation for board movement. - Board attacks now request class-specific motion profiles, with infantry strikes, archer traces, cavalry charges, command flashes, and heavy warrior/bandit impacts drawn by the battle scene. -- Battle maps can use a high-resolution background image with translucent terrain overlays and terrain-specific detail patterns. +- Battle maps can use a high-resolution background image with translucent terrain overlays, terrain-specific detail patterns, connected roads, shoreline/castle/forest/hill edge blends, and generic-enemy token marks. - Hover target preview badges for attacks, tactics, and items. - Low-HP board unit warning rings, HP bar color states, and board status pips plus HUD duration summaries for support, debuff, poison, seal, snare, and disarm effects. - Data-driven scenario setup through JSON definitions and deployments. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a1e94f5..ae53251 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -59,6 +59,6 @@ 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, 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. 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, or finite-stock purchases; 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, and gear, board maps can draw a scenario background texture under translucent terrain overlays and terrain detail patterns, board units render class sprite tokens, team rings, class badges, 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, 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, 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 `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9139155..ce675b1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -136,7 +136,7 @@ - Portrait and art pipeline. Officer definitions can provide default portrait paths, classes can provide board `sprite` paths, items can provide `icon` paths, scenarios can provide map `background` paths, and the first generated officer portraits, unit sprites, item icons, and battlefield background are in `art/`. - Unit sprites and animations. Board movement now has a short slide animation, attacks request class-specific motion profiles for infantry, archers, cavalry, command units, and heavy warrior/bandit units, and board units show class sprite tokens, team rings, class badges, low-HP warning rings, and small status pips for active support, debuff, poison, seal, snare, and disarm effects, with HUD summaries showing remaining phases. -- Battlefield presentation. Maps can draw a high-resolution background under translucent terrain overlays, with first terrain-specific detail patterns for plains, forest, hill, road, water, and castle cells. +- Battlefield presentation. Maps can draw a high-resolution background under translucent terrain overlays, with terrain-specific detail patterns, connected road segments, shoreline/castle/forest/hill edge blends, and generic-enemy token marks. - Battle HUD presentation. Selected and hovered units now summarize battlefield role, movement type, current terrain move cost, defense/avoid bonuses, core stats, status effects, and gear so class identity reads alongside the visual tokens. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, item and gold pickups, AI target-focus changes, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items, and the board can overlay enemy threat ranges with physical damage and hostile tactic hints. - Music and sound. Basic placeholder BGM and SFX now play for menus, battle flow, unit actions, tactics, items, and result states. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 26a3e0c..741cc14 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -1089,6 +1089,7 @@ func _can_select(unit: Dictionary) -> bool: func _draw_map() -> void: var board_rect := Rect2(BOARD_OFFSET, Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE) var background := _current_battle_background_texture() + var has_background := background != null if background != null: _draw_texture_cover(background, board_rect, Color(1.0, 1.0, 1.0, 0.96)) draw_rect(board_rect, Color(0.02, 0.025, 0.03, 0.12)) @@ -1100,11 +1101,10 @@ func _draw_map() -> void: var cell := Vector2i(x, y) var rect := _rect_for_cell(cell) var terrain_key := state.get_terrain_key(cell) - var terrain_color := state.get_terrain_color(cell) - terrain_color.a = 0.26 - draw_rect(rect, terrain_color) + draw_rect(rect, _terrain_fill_color(cell, terrain_key, has_background)) _draw_terrain_detail(cell, rect, terrain_key) - draw_rect(rect, GRID_COLOR, false, 1.0) + _draw_terrain_edge_blend(cell, rect, terrain_key) + draw_rect(rect, _map_grid_color(has_background), false, 1.0) draw_rect(board_rect, Color(0.02, 0.025, 0.03), false, 3.0) @@ -1138,6 +1138,23 @@ func _draw_texture_cover(texture: Texture2D, rect: Rect2, modulate: Color) -> vo draw_texture_rect_region(texture, rect, source_rect, modulate) +func _terrain_fill_color(cell: Vector2i, terrain_key: String, has_background: bool) -> Color: + var color := state.get_terrain_color(cell) + var alpha := 0.16 if has_background else 0.34 + if terrain_key == "W": + alpha += 0.03 + elif terrain_key == "C" or terrain_key == "H": + alpha += 0.02 + color.a = alpha + return color + + +func _map_grid_color(has_background: bool) -> Color: + var color := GRID_COLOR + color.a = 0.23 if has_background else 0.52 + return color + + func _draw_terrain_detail(cell: Vector2i, rect: Rect2, terrain_key: String) -> void: if terrain_key == "F": for index in range(4): @@ -1151,25 +1168,159 @@ func _draw_terrain_detail(cell: Vector2i, rect: Rect2, terrain_key: String) -> v draw_line(point + Vector2(-10, 8), peak, Color(0.88, 0.84, 0.72, 0.38), 2.0) draw_line(peak, point + Vector2(10, 8), Color(0.22, 0.20, 0.16, 0.34), 2.0) elif terrain_key == "R": - var road_color := Color(0.82, 0.69, 0.48, 0.34) - draw_rect(Rect2(rect.position + Vector2(rect.size.x * 0.36, 0), Vector2(rect.size.x * 0.28, rect.size.y)), road_color) - draw_line(rect.position + Vector2(rect.size.x * 0.48, 6), rect.position + Vector2(rect.size.x * 0.52, rect.size.y - 6), Color(0.36, 0.28, 0.18, 0.24), 1.5) + _draw_road_detail(cell, rect) elif terrain_key == "W": for index in range(3): var y := rect.position.y + 15.0 + float(index) * 14.0 draw_line(rect.position + Vector2(10, y - rect.position.y), rect.position + Vector2(rect.size.x - 10, y - rect.position.y + 4), Color(0.70, 0.88, 1.0, 0.32), 1.6) + _draw_water_shoreline(cell, rect) elif terrain_key == "C": draw_rect(rect.grow(-7.0), Color(0.16, 0.15, 0.14, 0.28), false, 2.0) draw_line(rect.position + Vector2(10, 21), rect.position + Vector2(rect.size.x - 10, 21), Color(0.84, 0.80, 0.72, 0.30), 1.4) draw_line(rect.position + Vector2(10, 39), rect.position + Vector2(rect.size.x - 10, 39), Color(0.84, 0.80, 0.72, 0.22), 1.2) draw_line(rect.position + Vector2(23, 11), rect.position + Vector2(23, rect.size.y - 11), Color(0.05, 0.05, 0.05, 0.20), 1.2) draw_line(rect.position + Vector2(43, 11), rect.position + Vector2(43, rect.size.y - 11), Color(0.05, 0.05, 0.05, 0.20), 1.2) + _draw_castle_detail(rect) else: for index in range(2): var point := _terrain_detail_point(cell, rect, index + 15) draw_line(point, point + Vector2(3, -7), Color(0.78, 0.88, 0.50, 0.26), 1.2) +func _draw_road_detail(cell: Vector2i, rect: Rect2) -> void: + var road_color := Color(0.82, 0.69, 0.48, 0.34) + var rut_color := Color(0.36, 0.28, 0.18, 0.24) + var center := rect.position + rect.size * 0.5 + var flags := _terrain_connection_flags(cell, "R") + var north := bool(flags.get("north", false)) + var south := bool(flags.get("south", false)) + var west := bool(flags.get("west", false)) + var east := bool(flags.get("east", false)) + if north or south or not (west or east): + var from_y := rect.position.y if north else center.y + var to_y := rect.end.y if south else center.y + draw_rect(Rect2(Vector2(center.x - rect.size.x * 0.14, from_y), Vector2(rect.size.x * 0.28, to_y - from_y)), road_color) + draw_line(Vector2(center.x - 5.0, from_y + 5.0), Vector2(center.x - 2.0, to_y - 5.0), rut_color, 1.2) + draw_line(Vector2(center.x + 5.0, from_y + 5.0), Vector2(center.x + 2.0, to_y - 5.0), rut_color, 1.2) + if west or east: + var from_x := rect.position.x if west else center.x + var to_x := rect.end.x if east else center.x + draw_rect(Rect2(Vector2(from_x, center.y - rect.size.y * 0.14), Vector2(to_x - from_x, rect.size.y * 0.28)), road_color) + draw_line(Vector2(from_x + 5.0, center.y - 5.0), Vector2(to_x - 5.0, center.y - 2.0), rut_color, 1.2) + draw_line(Vector2(from_x + 5.0, center.y + 5.0), Vector2(to_x - 5.0, center.y + 2.0), rut_color, 1.2) + draw_circle(center, 5.0, Color(0.84, 0.70, 0.48, 0.24)) + + +func _draw_water_shoreline(cell: Vector2i, rect: Rect2) -> void: + var shore := Color(0.82, 0.88, 0.72, 0.24) + if _terrain_key_at(cell + Vector2i(0, -1)) != "W": + draw_line(rect.position + Vector2(7, 4), rect.position + Vector2(rect.size.x - 7, 6), shore, 2.0) + if _terrain_key_at(cell + Vector2i(0, 1)) != "W": + draw_line(rect.position + Vector2(7, rect.size.y - 6), rect.position + Vector2(rect.size.x - 7, rect.size.y - 4), shore, 2.0) + if _terrain_key_at(cell + Vector2i(-1, 0)) != "W": + draw_line(rect.position + Vector2(4, 7), rect.position + Vector2(6, rect.size.y - 7), shore, 2.0) + if _terrain_key_at(cell + Vector2i(1, 0)) != "W": + draw_line(rect.position + Vector2(rect.size.x - 6, 7), rect.position + Vector2(rect.size.x - 4, rect.size.y - 7), shore, 2.0) + + +func _draw_castle_detail(rect: Rect2) -> void: + for index in range(3): + var notch := Rect2(rect.position + Vector2(11.0 + float(index) * 16.0, 7.0), Vector2(8.0, 5.0)) + draw_rect(notch, Color(0.84, 0.80, 0.72, 0.22)) + + +func _draw_terrain_edge_blend(cell: Vector2i, rect: Rect2, terrain_key: String) -> void: + _draw_terrain_edge(cell, rect, terrain_key, Vector2i(0, -1)) + _draw_terrain_edge(cell, rect, terrain_key, Vector2i(1, 0)) + _draw_terrain_edge(cell, rect, terrain_key, Vector2i(0, 1)) + _draw_terrain_edge(cell, rect, terrain_key, Vector2i(-1, 0)) + + +func _draw_terrain_edge(cell: Vector2i, rect: Rect2, terrain_key: String, offset: Vector2i) -> void: + var neighbor_key := _terrain_key_at(cell + offset) + if not _should_draw_terrain_edge(terrain_key, neighbor_key, offset): + return + var color := _terrain_edge_color(terrain_key, neighbor_key) + if color.a <= 0.0: + return + var start := Vector2.ZERO + var end := Vector2.ZERO + if offset == Vector2i(0, -1): + start = rect.position + Vector2(3, 3) + end = rect.position + Vector2(rect.size.x - 3, 4 + _cell_noise(cell, 73) * 2.0) + elif offset == Vector2i(1, 0): + start = rect.position + Vector2(rect.size.x - 4, 3) + end = rect.position + Vector2(rect.size.x - 3, rect.size.y - 3) + elif offset == Vector2i(0, 1): + start = rect.position + Vector2(3, rect.size.y - 4) + end = rect.position + Vector2(rect.size.x - 3, rect.size.y - 3) + else: + start = rect.position + Vector2(3, 3) + end = rect.position + Vector2(4 + _cell_noise(cell, 91) * 2.0, rect.size.y - 3) + draw_line(start, end, color, _terrain_edge_width(terrain_key, neighbor_key)) + + +func _should_draw_terrain_edge(terrain_key: String, neighbor_key: String, offset: Vector2i) -> bool: + if neighbor_key.is_empty() or neighbor_key == terrain_key: + return false + var priority := _terrain_edge_priority(terrain_key) + var neighbor_priority := _terrain_edge_priority(neighbor_key) + if priority != neighbor_priority: + return priority > neighbor_priority + return offset.x > 0 or offset.y > 0 + + +func _terrain_edge_priority(terrain_key: String) -> int: + if terrain_key == "W": + return 60 + if terrain_key == "C": + return 50 + if terrain_key == "F": + return 40 + if terrain_key == "H": + return 30 + if terrain_key == "R": + return 20 + return 10 + + +func _terrain_edge_color(terrain_key: String, neighbor_key: String) -> Color: + if terrain_key == "F": + return Color(0.03, 0.16, 0.06, 0.34) + if terrain_key == "H": + return Color(0.88, 0.78, 0.48, 0.26) + if terrain_key == "W": + return Color(0.72, 0.90, 1.0, 0.28) + if terrain_key == "C": + return Color(0.92, 0.86, 0.72, 0.24) + if neighbor_key == "W": + return Color(0.08, 0.18, 0.20, 0.18) + return Color(0.96, 0.92, 0.62, 0.12) + + +func _terrain_edge_width(terrain_key: String, neighbor_key: String) -> float: + if terrain_key == "W" or neighbor_key == "W": + return 2.4 + if terrain_key == "C": + return 2.0 + return 1.4 + + +func _terrain_key_at(cell: Vector2i) -> String: + if not state.is_inside(cell): + return "" + return state.get_terrain_key(cell) + + +func _terrain_connection_flags(cell: Vector2i, terrain_key: String) -> Dictionary: + return { + "north": _terrain_key_at(cell + Vector2i(0, -1)) == terrain_key, + "south": _terrain_key_at(cell + Vector2i(0, 1)) == terrain_key, + "west": _terrain_key_at(cell + Vector2i(-1, 0)) == terrain_key, + "east": _terrain_key_at(cell + Vector2i(1, 0)) == terrain_key + } + + func _terrain_detail_point(cell: Vector2i, rect: Rect2, salt: int) -> Vector2: return rect.position + Vector2( 10.0 + _cell_noise(cell, salt) * (rect.size.x - 20.0), @@ -1242,7 +1393,7 @@ func _draw_units() -> void: var team_color := PLAYER_COLOR if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR var token_back := Color(0.02, 0.022, 0.026, 0.78) var acted := bool(unit.get("acted", false)) - var sprite_modulate := Color(0.60, 0.62, 0.66, 0.82) if acted else Color.WHITE + var sprite_modulate := _unit_sprite_modulate(unit, acted) draw_circle(center + Vector2(0, 3), 29, Color(0.0, 0.0, 0.0, 0.38)) draw_circle(center, 28, token_back) @@ -1256,6 +1407,7 @@ func _draw_units() -> void: draw_circle(center, 22, body_color) draw_arc(center, 28, 0.0, TAU, 64, Color(0.03, 0.035, 0.04), 3.0) draw_arc(center, 28, -PI * 0.65, PI * 0.35, 36, team_color.lightened(0.18), 3.0) + _draw_unit_identity_marks(rect, center, unit, team_color) _draw_unit_class_badge(rect, unit, team_color) var label := _short_name(String(unit["name"])) @@ -1281,6 +1433,33 @@ func _fit_texture_rect(texture: Texture2D, target: Rect2) -> Rect2: return Rect2(target.position + (target.size - fit_size) * 0.5, fit_size) +func _unit_sprite_modulate(unit: Dictionary, acted: bool) -> Color: + if acted: + return Color(0.60, 0.62, 0.66, 0.82) + if _is_generic_enemy_unit(unit): + return Color(1.0, 0.82, 0.74, 0.94) + return Color.WHITE + + +func _draw_unit_identity_marks(rect: Rect2, center: Vector2, unit: Dictionary, team_color: Color) -> void: + if _is_generic_enemy_unit(unit): + var sash := ENEMY_COLOR.lightened(0.22) + sash.a = 0.48 + draw_line(rect.position + Vector2(12, 48), rect.position + Vector2(50, 14), sash, 4.0) + draw_circle(center + Vector2(18, -17), 5.0, Color(0.05, 0.02, 0.02, 0.78)) + draw_circle(center + Vector2(18, -17), 3.2, ENEMY_COLOR.lightened(0.18)) + return + var officer_id := str(unit.get("officer_id", "")) + if not officer_id.is_empty(): + draw_circle(center + Vector2(18, -17), 5.0, Color(0.06, 0.045, 0.02, 0.82)) + draw_circle(center + Vector2(18, -17), 3.0, Color(1.0, 0.84, 0.34, 0.88)) + draw_arc(center, 25.0, -PI * 0.18, PI * 0.32, 18, team_color.lightened(0.34), 1.8) + + +func _is_generic_enemy_unit(unit: Dictionary) -> bool: + return unit.get("team", "") == BattleState.TEAM_ENEMY and str(unit.get("officer_id", "")).is_empty() + + func _draw_unit_class_badge(rect: Rect2, unit: Dictionary, team_color: Color) -> void: var badge_rect := Rect2(rect.position + Vector2(5, 5), Vector2(28, 13)) draw_rect(badge_rect, Color(0.02, 0.022, 0.026, 0.82)) diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 41fd737..710dd18 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -9,6 +9,7 @@ func _init() -> void: _check_battle_visual_data(failures) _check_scene_texture_loading(failures) _check_hud_focus_text(failures) + _check_terrain_and_unit_presentation(failures) _check_attack_motion_profiles(failures) _check_attack_motion_signal(failures) @@ -120,6 +121,65 @@ func _check_hud_focus_text(failures: Array[String]) -> void: scene.free() +func _check_terrain_and_unit_presentation(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 battle scene state for presentation helpers") + scene.free() + return + if scene._terrain_key_at(Vector2i(3, 1)) != "F": + failures.append("terrain helper should read forest cells") + if not scene._terrain_key_at(Vector2i(-1, 0)).is_empty(): + failures.append("terrain helper should return empty outside the board") + 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: + failures.append("background terrain fill should stay translucent over high-res art") + if fallback_fill.a <= background_fill.a: + failures.append("fallback terrain fill should be stronger than background fill") + if scene._map_grid_color(true).a >= scene._map_grid_color(false).a: + failures.append("background grid should be lighter than fallback grid") + if scene._terrain_edge_color("F", "G").a <= 0.0: + failures.append("forest edge blend should be visible") + if not scene._should_draw_terrain_edge("F", "G", Vector2i(1, 0)): + failures.append("forest should own forest/plain edge drawing") + if scene._should_draw_terrain_edge("G", "F", Vector2i(-1, 0)): + failures.append("plain should not redraw forest-owned edge") + if scene._should_draw_terrain_edge("F", "F", Vector2i(1, 0)): + failures.append("matching terrain should not draw edge blends") + if scene._terrain_edge_width("W", "G") <= scene._terrain_edge_width("G", "F"): + failures.append("water shoreline edge should be wider than grass blend") + if not scene.state.load_battle("res://data/scenarios/002_sishui_gate.json"): + failures.append("could not load Sishui Gate for road presentation helpers") + else: + var road_flags: Dictionary = scene._terrain_connection_flags(Vector2i(2, 4), "R") + if not bool(road_flags.get("west", false)) or not bool(road_flags.get("east", false)): + failures.append("Sishui Gate road should connect west/east at 3,5: %s" % str(road_flags)) + if bool(road_flags.get("north", false)) or bool(road_flags.get("south", false)): + failures.append("Sishui Gate road should not connect north/south at 3,5: %s" % str(road_flags)) + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not reload opening battle for unit presentation helpers") + scene.free() + return + var generic_enemy: Dictionary = scene.state.get_unit("yellow_turban_2") + var named_officer: Dictionary = scene.state.get_unit("cao_cao") + if not scene._is_generic_enemy_unit(generic_enemy): + failures.append("generic enemy unit should be marked as generic enemy") + if scene._is_generic_enemy_unit(named_officer): + failures.append("named officer should not be marked as generic enemy") + if scene._is_generic_enemy_unit({"team": "enemy", "officer_id": "named_enemy"}): + failures.append("enemy with officer_id should not be marked as generic") + if scene._is_generic_enemy_unit({"team": "player", "officer_id": ""}): + failures.append("player unit should not be marked as generic enemy") + var enemy_modulate: Color = scene._unit_sprite_modulate(generic_enemy, false) + if enemy_modulate == Color.WHITE or enemy_modulate.a >= 1.0: + failures.append("generic enemy sprite should receive a distinct semi-real tint") + var acted_modulate: Color = scene._unit_sprite_modulate(generic_enemy, true) + if acted_modulate.a >= enemy_modulate.a: + failures.append("acted generic enemy sprite should still dim below active tint") + scene.free() + + func _check_attack_motion_profiles(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):