diff --git a/README.md b/README.md index f038733..baac64c 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Victory and defeat result overlay. - Dialogue portrait slot with officer default image paths, optional per-line overrides, and speaker-initial fallback. - Initial AI-generated photorealistic officer portraits for Cao Cao, Xiahou Dun, Xiahou Yuan, Cao Ren, Dian Wei, Guo Jia, and Zhang He. -- Initial AI-generated battlefield, class unit, and item icon assets live under `art/backgrounds`, `art/units`, and `art/items`. +- Initial AI-generated battlefield, class unit, transparent generic-enemy unit, and item icon assets live under `art/backgrounds`, `art/units`, and `art/items`. - Battle HUD portrait thumbnails show the selected unit, or a hovered unit when nothing is selected. - Selected and hovered unit focus text shows combat role, movement type, current terrain move cost, terrain bonuses, core stats, and status effects. - Dialogue lines can place the portrait on the left or right side of an expanded visual-novel-style dialogue panel with progress and previous-line controls. @@ -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, terrain-specific detail patterns, connected roads, shoreline/castle/forest/hill edge blends, and generic-enemy token marks. +- 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, transparent generic-enemy class art, and 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/art/units/enemies/enemy_archer.png b/art/units/enemies/enemy_archer.png new file mode 100644 index 0000000..4d95c5a Binary files /dev/null and b/art/units/enemies/enemy_archer.png differ diff --git a/art/units/enemies/enemy_cavalry.png b/art/units/enemies/enemy_cavalry.png new file mode 100644 index 0000000..bc7a1d2 Binary files /dev/null and b/art/units/enemies/enemy_cavalry.png differ diff --git a/art/units/enemies/enemy_infantry.png b/art/units/enemies/enemy_infantry.png new file mode 100644 index 0000000..cd71163 Binary files /dev/null and b/art/units/enemies/enemy_infantry.png differ diff --git a/art/units/enemies/enemy_strategist.png b/art/units/enemies/enemy_strategist.png new file mode 100644 index 0000000..8c19359 Binary files /dev/null and b/art/units/enemies/enemy_strategist.png differ diff --git a/art/units/enemies/enemy_warrior.png b/art/units/enemies/enemy_warrior.png new file mode 100644 index 0000000..8fce106 Binary files /dev/null and b/art/units/enemies/enemy_warrior.png differ diff --git a/data/defs/classes.json b/data/defs/classes.json index 8a69b99..c81c6d4 100644 --- a/data/defs/classes.json +++ b/data/defs/classes.json @@ -19,6 +19,7 @@ "commander": { "name": "Commander", "sprite": "res://art/units/strategist.png", + "enemy_sprite": "res://art/units/enemies/enemy_strategist.png", "tier": 2, "move_type": "foot", "move": 5, @@ -35,6 +36,7 @@ "cavalry": { "name": "Cavalry", "sprite": "res://art/units/cavalry.png", + "enemy_sprite": "res://art/units/enemies/enemy_cavalry.png", "tier": 1, "move_type": "mounted", "move": 5, @@ -52,6 +54,7 @@ "elite_cavalry": { "name": "Elite Cavalry", "sprite": "res://art/units/cavalry.png", + "enemy_sprite": "res://art/units/enemies/enemy_cavalry.png", "tier": 2, "move_type": "mounted", "move": 6, @@ -68,6 +71,7 @@ "infantry": { "name": "Infantry", "sprite": "res://art/units/infantry.png", + "enemy_sprite": "res://art/units/enemies/enemy_infantry.png", "tier": 1, "move_type": "foot", "move": 4, @@ -85,6 +89,7 @@ "guard_captain": { "name": "Guard Captain", "sprite": "res://art/units/infantry.png", + "enemy_sprite": "res://art/units/enemies/enemy_infantry.png", "tier": 2, "move_type": "foot", "move": 5, @@ -101,6 +106,7 @@ "warrior": { "name": "Warrior", "sprite": "res://art/units/warrior.png", + "enemy_sprite": "res://art/units/enemies/enemy_warrior.png", "tier": 1, "move_type": "foot", "move": 4, @@ -118,6 +124,7 @@ "champion": { "name": "Champion", "sprite": "res://art/units/warrior.png", + "enemy_sprite": "res://art/units/enemies/enemy_warrior.png", "tier": 2, "move_type": "foot", "move": 5, @@ -134,6 +141,7 @@ "archer": { "name": "Archer", "sprite": "res://art/units/archer.png", + "enemy_sprite": "res://art/units/enemies/enemy_archer.png", "tier": 1, "move_type": "archer", "move": 4, @@ -151,6 +159,7 @@ "marksman": { "name": "Marksman", "sprite": "res://art/units/archer.png", + "enemy_sprite": "res://art/units/enemies/enemy_archer.png", "tier": 2, "move_type": "archer", "move": 5, @@ -167,6 +176,7 @@ "strategist": { "name": "Strategist", "sprite": "res://art/units/strategist.png", + "enemy_sprite": "res://art/units/enemies/enemy_strategist.png", "tier": 1, "move_type": "foot", "move": 4, @@ -184,6 +194,7 @@ "military_advisor": { "name": "Military Advisor", "sprite": "res://art/units/strategist.png", + "enemy_sprite": "res://art/units/enemies/enemy_strategist.png", "tier": 2, "move_type": "foot", "move": 4, @@ -200,6 +211,7 @@ "bandit": { "name": "Bandit", "sprite": "res://art/units/warrior.png", + "enemy_sprite": "res://art/units/enemies/enemy_warrior.png", "tier": 1, "move_type": "foot", "move": 4, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f0dc4f8..090426a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ - `scripts/core/battle_state.gd`: Battle rules, unit state, turns, AI, and victory checks. - `scripts/scenes/battle_scene.gd`: Rendering, input handling, and HUD wiring. - `audio/bgm/*.wav` and `audio/sfx/*.wav`: Placeholder music loops and interface/action stingers. -- `art/portraits/*.png`, `art/units/*.png`, `art/items/*.png`, and `art/backgrounds/*.png`: Generated visual assets used by dialogue, battle HUD, board tokens, item buttons, and map backdrops. +- `art/portraits/*.png`, `art/units/*.png`, `art/units/enemies/*.png`, `art/items/*.png`, and `art/backgrounds/*.png`: Generated visual assets used by dialogue, battle HUD, board tokens, generic-enemy transparent cutouts, item buttons, and map backdrops. - `data/campaign/campaign.json`: Campaign chapter ranges, scenario order, and scenario paths. - `data/defs/*.json`: Officers, classes, terrain, items, and skills. - `data/scenarios/*.json`: Scenario definitions. @@ -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, 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, 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 battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, dialogue lines can show optional cached portrait textures or speaker-initial fallback panels in an expanded visual-novel-style panel, can place the portrait on the left or right side of that panel, and expose progress plus previous-line controls while a sequence is open, the side HUD reuses cached officer portrait textures or class sprites for the selected unit or hovered unit and formats focus text with role, movement type, current terrain move cost, defense/avoid bonuses, core stats, status effects, and gear, board maps can draw a scenario background texture under translucent terrain overlays, connected road segments, terrain detail patterns, and shoreline/castle/forest/hill edge blends, board units render class sprite tokens or generic-enemy transparent class cutouts, team rings, class badges, generic-enemy marks, officer pins, low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, item buttons can show optional cached item icons, hover previews are rendered as target badges from existing `BattleState` forecast APIs, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, objective updates, AI target-focus changes, and item/gold pickup effects use distinct feedback, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, `BattleState.unit_action_motion_requested` keeps its rules-facing signal shape while the scene maps attacker class ids to infantry, archer, cavalry, command, and heavy attack presentation profiles, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. 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/DATA_MODEL.md b/docs/DATA_MODEL.md index 595f0bd..a40ceac 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -219,7 +219,7 @@ Classes provide movement, min/max attack range, skill access, growth grades, cla ``` `attack_range` is `[min, max]` in Manhattan distance. A range of `[2, 2]` means the unit cannot attack adjacent targets. -`sprite` is an optional class-level board token image used by hydrated units unless an officer or deployment overrides it. +`sprite` is an optional class-level board token image used by hydrated units unless an officer or deployment overrides it. `enemy_sprite` is an optional class-level image for generic enemy units with `team: "enemy"` and no `officer_id`; runtime sprite precedence is deployment `sprite`, officer `sprite`, generic enemy class `enemy_sprite`, then class `sprite`. The current enemy class art is stored as transparent PNG cutouts under `res://art/units/enemies/` so named officers and player units can keep their cleaner class or officer presentation while ordinary troops read as their own force. `promotion.level` and `promotion.to` define an automatic class promotion route. When a unit reaches the threshold during battle, the runtime updates class name, movement, movement type, growth, class skills, attack range, and class base-bonus deltas. The promoted `class_id` and derived roster fields are captured in the campaign roster snapshot, and the next battle rehydrates class-derived fields from the saved `class_id`. Promotion targets must be higher-tier classes and must keep all source equipment slot permissions unless runtime auto-unequip logic is added later. The current core routes cover hero, cavalry, infantry, archer, warrior, and strategist first-tier classes. diff --git a/scripts/core/data_catalog.gd b/scripts/core/data_catalog.gd index 3c2e7f5..9432140 100644 --- a/scripts/core/data_catalog.gd +++ b/scripts/core/data_catalog.gd @@ -29,6 +29,9 @@ func hydrate_deployment(deployment: Dictionary) -> Dictionary: var class_def := _get_dict(classes, class_id) var equipment := _merged_equipment(officer.get("equipment", {}), deployment.get("equipment", {})) var range_pair := _resolve_attack_range(class_def, equipment) + var unit_officer_id := str(deployment.get("officer_id", "")) + var unit_team := str(deployment.get("team", "enemy")) + var sprite_info := _resolved_unit_sprite(deployment, officer, class_def, unit_team, unit_officer_id) var stats := {} _overlay_stats(stats, officer.get("base", {})) @@ -38,13 +41,14 @@ func hydrate_deployment(deployment: Dictionary) -> Dictionary: var unit := {} unit["id"] = str(deployment.get("unit_id", deployment.get("id", deployment.get("officer_id", "")))) - unit["officer_id"] = str(deployment.get("officer_id", "")) + unit["officer_id"] = unit_officer_id unit["name"] = str(deployment.get("name", officer.get("name", unit["id"]))) unit["portrait"] = str(deployment.get("portrait", officer.get("portrait", ""))) - unit["sprite"] = str(deployment.get("sprite", officer.get("sprite", class_def.get("sprite", "")))) + unit["sprite"] = str(sprite_info.get("path", "")) + unit["uses_enemy_sprite"] = bool(sprite_info.get("uses_enemy_sprite", false)) unit["class_id"] = class_id unit["class"] = str(class_def.get("name", class_id.capitalize())) - unit["team"] = str(deployment.get("team", "enemy")) + unit["team"] = unit_team unit["pos"] = deployment.get("pos", [0, 0]) unit["requires_joined"] = bool(deployment.get("requires_joined", false)) unit["controllable"] = bool(deployment.get("controllable", true)) @@ -76,6 +80,19 @@ func hydrate_deployment(deployment: Dictionary) -> Dictionary: return unit +func _resolved_unit_sprite(deployment: Dictionary, officer: Dictionary, class_def: Dictionary, team: String, officer_id: String) -> Dictionary: + var deployment_sprite := str(deployment.get("sprite", "")).strip_edges() + if not deployment_sprite.is_empty(): + return {"path": deployment_sprite, "uses_enemy_sprite": false} + var officer_sprite := str(officer.get("sprite", "")).strip_edges() + if not officer_sprite.is_empty(): + return {"path": officer_sprite, "uses_enemy_sprite": false} + var enemy_sprite := str(class_def.get("enemy_sprite", "")).strip_edges() + if team == "enemy" and officer_id.strip_edges().is_empty() and not enemy_sprite.is_empty(): + return {"path": enemy_sprite, "uses_enemy_sprite": true} + return {"path": str(class_def.get("sprite", "")).strip_edges(), "uses_enemy_sprite": false} + + func get_runtime_terrain_defs(default_defs: Dictionary) -> Dictionary: var result := default_defs.duplicate(true) for key in terrain.keys(): diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 011e2c1..ba0cb27 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -1436,6 +1436,8 @@ func _fit_texture_rect(texture: Texture2D, target: Rect2) -> Rect2: func _unit_sprite_modulate(unit: Dictionary, acted: bool) -> Color: if acted: return Color(0.60, 0.62, 0.66, 0.82) + if bool(unit.get("uses_enemy_sprite", false)): + return Color(1.0, 1.0, 1.0, 0.98) if _is_generic_enemy_unit(unit): return Color(1.0, 0.82, 0.74, 0.94) return Color.WHITE diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index cc1cc85..b8bdb55 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -51,6 +51,16 @@ func _check_battle_visual_data(failures: Array[String]) -> void: failures.append("missing expected unit: %s" % unit_id) continue _check_image_path(failures, str(unit.get("sprite", "")), "unit %s sprite" % unit_id) + _check_unit_sprite_resolution(failures, state, "cao_cao", "res://art/units/strategist.png", false) + _check_unit_sprite_resolution(failures, state, "yellow_turban_1", "res://art/units/enemies/enemy_warrior.png", true) + _check_unit_sprite_resolution(failures, state, "yellow_turban_2", "res://art/units/enemies/enemy_infantry.png", true) + _check_unit_sprite_resolution(failures, state, "yellow_turban_3", "res://art/units/enemies/enemy_archer.png", true) + + var gate_state = BattleStateScript.new() + if not gate_state.load_battle("res://data/scenarios/002_sishui_gate.json"): + failures.append("could not load Sishui Gate for enemy cavalry sprite") + else: + _check_unit_sprite_resolution(failures, gate_state, "hua_xiong_vanguard", "res://art/units/enemies/enemy_cavalry.png", true) for item_id in ["bronze_sword", "training_spear", "short_bow", "hand_axe", "iron_armor", "panacea"]: var item := state.get_item_def(item_id) @@ -178,8 +188,11 @@ func _check_terrain_and_unit_presentation(failures: Array[String]) -> void: 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") + if enemy_modulate.r < 0.99 or enemy_modulate.g < 0.99 or enemy_modulate.b < 0.99 or enemy_modulate.a < 0.97: + failures.append("transparent enemy class sprite should render near its original colors") + var fallback_enemy_modulate: Color = scene._unit_sprite_modulate({"team": "enemy", "officer_id": ""}, false) + if fallback_enemy_modulate == Color.WHITE or fallback_enemy_modulate.a >= 1.0: + failures.append("generic enemy fallback sprite should still 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") @@ -322,6 +335,19 @@ func _check_camp_conversation(failures: Array[String], conversation: Dictionary, failures.append("camp conversation officer portrait missing for %s" % expected_id) +func _check_unit_sprite_resolution(failures: Array[String], state, unit_id: String, expected_sprite: String, expected_enemy_sprite: bool) -> void: + var unit: Dictionary = state.get_unit(unit_id) + if unit.is_empty(): + failures.append("missing unit for sprite resolution: %s" % unit_id) + return + var sprite := str(unit.get("sprite", "")) + if sprite != expected_sprite: + failures.append("%s sprite mismatch: expected %s got %s" % [unit_id, expected_sprite, sprite]) + if bool(unit.get("uses_enemy_sprite", false)) != expected_enemy_sprite: + failures.append("%s enemy sprite flag mismatch: expected %s got %s" % [unit_id, str(expected_enemy_sprite), str(unit.get("uses_enemy_sprite", false))]) + _check_image_path(failures, sprite, "unit %s resolved sprite" % unit_id) + + func _check_image_path(failures: Array[String], path: String, context: String) -> void: if path.is_empty(): failures.append("%s has an empty image path" % context) diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index bc39f2f..7e74d2e 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -1926,6 +1926,9 @@ foreach ($classProp in $classes.PSObject.Properties) { if (Has-Prop $classProp.Value "sprite") { Check-Portrait-Path (Get-Prop $classProp.Value "sprite" "") "Class $($classProp.Name) sprite" } + if (Has-Prop $classProp.Value "enemy_sprite") { + Check-Portrait-Path (Get-Prop $classProp.Value "enemy_sprite" "") "Class $($classProp.Name) enemy_sprite" + } } $officerNames = New-Object System.Collections.Generic.HashSet[string]