diff --git a/README.md b/README.md index 4e3d90a..cfdd533 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,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. +- Battle HUD portrait thumbnails show the selected unit, or a hovered unit when nothing is selected. - 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. - Placeholder menu/battle BGM plus UI, movement, attack, tactic, item, victory, and defeat SFX. - Floating combat text for damage, recovery, misses, support effects, poison ticks, action locks, level-ups, and promotions. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8c1dbf3..49d907b 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`: Generated officer portrait textures used by dialogue presentation. +- `art/portraits/*.png`: Generated officer portrait textures used by dialogue and battle HUD presentation. - `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 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. 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. 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. 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. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item pickups, and reinforcements from saved campaign choices. Consumable use can restore HP or MP or cure matching poison/seal/snare/disarm statuses, and in-battle item use, event pickup grants, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items, 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, 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 and objective text, active battles use battle BGM, 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, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, hover unit info shows class, movement type, AGI, movement, range, and equipped gear, 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, and item pickup effects use distinct floating feedback colors, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, 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 and objective text, active battles use battle BGM, 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 for the selected unit or hovered unit, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, hover unit info shows class, movement type, AGI, movement, range, and equipped gear, 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, and item pickup effects use distinct floating feedback colors, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, 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 f035b5a..b11e3d8 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -174,7 +174,7 @@ The campaign file defines chapter ranges, scenario order, resource paths, and of ## Officers -Officers hold identity, personal base stats, initial class, and starting equipment. +Officers hold identity, personal base stats, initial class, default portrait path, and starting equipment. `DataCatalog` copies the portrait path into hydrated runtime units so dialogue and battle HUD presentation can share the same asset. ```json { @@ -182,6 +182,7 @@ Officers hold identity, personal base stats, initial class, and starting equipme "name": "Cao Cao", "faction": "wei", "class_id": "hero", + "portrait": "res://art/portraits/cao_cao.png", "level": 1, "exp": 0, "base": { "hp": 38, "mp": 10, "atk": 13, "def": 8, "int": 12, "agi": 8 }, @@ -355,7 +356,7 @@ Scenario `formation.cells` controls the highlighted starting cells available dur Scenarios should use deployments rather than full inline unit stat blocks. `DataCatalog` will resolve officer, class, and item references into runtime unit dictionaries. Scenarios may also define briefing text, visible objective text, battle conditions, and victory rewards. -Scenario deployments may override `move_type` for special units. For example, a river boat can keep an existing combat class but set `"move_type": "water"` so it can deploy and path on `W` water tiles while normal foot, mounted, and archer units treat those cells as impassable. Deployments can also override `equipment`; the validator checks those items against class equipment slots, and `DataCatalog` applies their stat bonuses and weapon ranges when hydrating the unit. +Scenario deployments may override `move_type` for special units. For example, a river boat can keep an existing combat class but set `"move_type": "water"` so it can deploy and path on `W` water tiles while normal foot, mounted, and archer units treat those cells as impassable. Deployments can also override `portrait` and `equipment`; the validator checks portrait files and equipment against class equipment slots, and `DataCatalog` applies their presentation paths, stat bonuses, and weapon ranges when hydrating the unit. ```json { @@ -544,7 +545,7 @@ Event-spawned units use the same compact deployment shape as scenario starting u ## Current Implementation -`scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, `growth_bonus`, equipment, and battle-only `status_effects` so the battle resolver can handle counterattacks, MP tactics, support buffs, enemy debuffs, movement modifiers, accuracy/evasion modifiers, damage-over-time statuses, action-lock statuses, EXP, level-ups, weapon-aware physical attack AI, and enemy tactic AI without changing scenario files. +`scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, `growth_bonus`, portrait, equipment, and battle-only `status_effects` so the battle resolver and presentation layer can handle counterattacks, MP tactics, support buffs, enemy debuffs, movement modifiers, accuracy/evasion modifiers, damage-over-time statuses, action-lock statuses, EXP, level-ups, weapon-aware physical attack AI, enemy tactic AI, and officer portrait thumbnails without changing scenario files. `scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, per-scenario shop purchase counts, and player roster progression. The same snapshot can be copied to the one-slot manual checkpoint at `user://campaign_manual_save.json` from the pre-battle Save menu. Loading that checkpoint restores it into the active automatic save and then reloads the pending choice panel, campaign completion state, or current scenario briefing. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, and post-battle choices are guarded by completed scenario id so restarting a finished battle does not duplicate gold, items, membership changes, branch decisions, or finite shop purchases. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves. @@ -560,4 +561,4 @@ Before a battle starts, `BattleScene` can buy priced items through `CampaignStat When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the automatic save file and resets `current_scenario_id` to `start_scenario`; the manual checkpoint is left alone until overwritten from the Save menu. -Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names and cure statuses, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event item grants, condition unit references, destination condition coordinates, campaign flag references, joined-officer gates, briefing line blocks, deployment ids, deployment bounds, impassable spawn cells, shop stock, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. +Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names and cure statuses, officer and deployment portraits, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event item grants, condition unit references, destination condition coordinates, campaign flag references, joined-officer gates, briefing line blocks, deployment ids, deployment bounds, impassable spawn cells, shop stock, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index b2b6f15..7bbec0a 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -47,7 +47,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Manual checkpoint save/load restores the expected current briefing without script errors. - A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear; weapon effectiveness appears in forecasts and equipment/shop text when applicable, and equipment options show stat/range change hints. - Named equipment rewards such as Yitian Sword and Dragon Spear validate, appear with compact rarity tags in reward/inventory/equipment text, and remain ordinary stackable inventory stock until unique item instances are implemented. -- Hovering an occupied tile shows the unit's class, movement type, AGI, movement, range, and equipped weapon/armor/accessory, with empty slots shown as `-`. +- Hovering an occupied tile shows the unit's class, movement type, AGI, movement, range, equipped weapon/armor/accessory, and a HUD portrait thumbnail when no unit is selected, with empty gear slots shown as `-`. - Movement-triggered supply cache events grant battle inventory items with pickup feedback, and the items persist only after victory. - The Threat toggle or `Y` key shows enemy physical and hostile tactic reach, hovered threatened tiles name the threatening units, and occupied player tiles show physical damage/hit estimates plus hostile tactic damage or status hints. - Area tactics such as Blaze, Great Mend, and Poison Mist highlight affected cells, apply only to valid targets in the area, and spend MP once per cast. @@ -62,7 +62,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Enemy turn advances and AI acts, including damage-aware physical target selection. - Victory and defeat panels still appear. - Briefings show victory/defeat text plus first-clear reward previews, while post-battle dialogue and post-battle choices still block progression until a choice is saved. -- Opening and post-battle dialogue can show generated officer portraits from `art/portraits` in the expanded dialogue panel, show sequence progress, allow stepping back to the previous line, and use speaker initials only when no portrait is available. +- Opening and post-battle dialogue can show generated officer portraits from `art/portraits` in the expanded dialogue panel, the side HUD can show selected or hovered unit portrait thumbnails, dialogue shows sequence progress, stepping back to the previous line works, and speaker initials are used only when no portrait is available. - Next battle loads with saved roster, inventory, joined officers, and campaign flags. - New Campaign clears the save and returns to the opening scenario. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 94b530a..544e25e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -132,7 +132,7 @@ ## Milestone 5: Presentation -- Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, missing art falls back to speaker initials, and the first seven AI-generated photorealistic officer portraits are in `art/portraits`. +- Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, battle HUD thumbnails reuse them for selected or hovered units, missing art falls back to speaker initials, and the first seven AI-generated photorealistic officer portraits are in `art/portraits`. - Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings plus small status pips for active support, debuff, poison, seal, snare, and disarm effects. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, item pickups, 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/core/battle_state.gd b/scripts/core/battle_state.gd index dda3cb7..4a51b35 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -469,6 +469,7 @@ func _prepare_unit(source_unit: Dictionary) -> Dictionary: unit["min_range"] = clampi(int(unit.get("min_range", min(1, int(unit["range"])))), 1, int(unit["range"])) unit["level"] = int(unit.get("level", 1)) unit["exp"] = int(unit.get("exp", 0)) + unit["portrait"] = _portrait_for_unit(unit) if not unit.has("growth") or typeof(unit["growth"]) != TYPE_DICTIONARY: unit["growth"] = {} if not unit.has("growth_bonus") or typeof(unit["growth_bonus"]) != TYPE_DICTIONARY: @@ -513,6 +514,13 @@ func _apply_roster_overlay(unit: Dictionary, roster_overrides) -> void: unit["loaded_from_roster"] = true +func _portrait_for_unit(unit: Dictionary) -> String: + var portrait := str(unit.get("portrait", "")) + if not portrait.is_empty(): + return portrait + return data_catalog.get_portrait_for_officer(str(unit.get("officer_id", ""))) + + func _find_roster_overlay(unit: Dictionary, roster_overrides: Dictionary) -> Dictionary: var unit_id := str(unit.get("id", "")) if roster_overrides.has(unit_id) and typeof(roster_overrides[unit_id]) == TYPE_DICTIONARY: diff --git a/scripts/core/data_catalog.gd b/scripts/core/data_catalog.gd index 99a62cc..62fb427 100644 --- a/scripts/core/data_catalog.gd +++ b/scripts/core/data_catalog.gd @@ -40,6 +40,7 @@ func hydrate_deployment(deployment: Dictionary) -> Dictionary: unit["id"] = str(deployment.get("unit_id", deployment.get("id", deployment.get("officer_id", "")))) unit["officer_id"] = str(deployment.get("officer_id", "")) unit["name"] = str(deployment.get("name", officer.get("name", unit["id"]))) + unit["portrait"] = str(deployment.get("portrait", officer.get("portrait", ""))) unit["class_id"] = class_id unit["class"] = str(class_def.get("name", class_id.capitalize())) unit["team"] = str(deployment.get("team", "enemy")) @@ -121,6 +122,13 @@ func get_portrait_for_speaker(speaker_name: String) -> String: return "" +func get_portrait_for_officer(officer_id: String) -> String: + if officer_id.is_empty(): + return "" + var officer := _get_dict(officers, officer_id) + return str(officer.get("portrait", "")) + + func _load_json_object(path: String) -> Dictionary: if not FileAccess.file_exists(path): push_error("Missing data file: %s" % path) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 3db2011..047edd6 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -48,6 +48,8 @@ const DIALOGUE_PANEL_SIZE := Vector2(1040, 210) const DIALOGUE_PORTRAIT_SIZE := Vector2(164, 186) const DIALOGUE_COLUMN_SIZE := Vector2(820, 186) const DIALOGUE_TEXT_SIZE := Vector2(800, 104) +const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104) +const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100) var state: BattleState = BattleStateScript.new() var campaign_state: CampaignState = CampaignStateScript.new() @@ -70,6 +72,9 @@ var campaign_complete_screen := false var status_label: Label var objective_label: Label var campaign_status_label: Label +var hud_unit_portrait_panel: PanelContainer +var hud_unit_portrait_texture: TextureRect +var hud_unit_portrait_label: Label var selected_label: Label var cell_info_label: Label var forecast_label: Label @@ -144,6 +149,7 @@ var dialogue_queue: Array = [] var active_dialogue_lines: Array = [] var active_dialogue_index := 0 var portrait_texture_cache: Dictionary = {} +var missing_portrait_paths := {} var bgm_player: AudioStreamPlayer var current_bgm_key := "" var audio_stream_cache := {} @@ -252,10 +258,36 @@ func _create_hud() -> void: side_column.add_theme_constant_override("separation", 10) side_panel.add_child(side_column) + var selected_row := HBoxContainer.new() + selected_row.add_theme_constant_override("separation", 8) + side_column.add_child(selected_row) + + hud_unit_portrait_panel = PanelContainer.new() + hud_unit_portrait_panel.custom_minimum_size = HUD_UNIT_PORTRAIT_SIZE + selected_row.add_child(hud_unit_portrait_panel) + + var hud_unit_portrait_stack := Control.new() + hud_unit_portrait_stack.custom_minimum_size = HUD_UNIT_PORTRAIT_STACK_SIZE + hud_unit_portrait_panel.add_child(hud_unit_portrait_stack) + + hud_unit_portrait_texture = TextureRect.new() + hud_unit_portrait_texture.set_anchors_preset(Control.PRESET_FULL_RECT) + hud_unit_portrait_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + hud_unit_portrait_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + hud_unit_portrait_stack.add_child(hud_unit_portrait_texture) + + hud_unit_portrait_label = Label.new() + hud_unit_portrait_label.set_anchors_preset(Control.PRESET_FULL_RECT) + hud_unit_portrait_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + hud_unit_portrait_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + hud_unit_portrait_label.add_theme_font_size_override("font_size", 24) + hud_unit_portrait_stack.add_child(hud_unit_portrait_label) + selected_label = Label.new() selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - selected_label.custom_minimum_size = Vector2(420, 92) - side_column.add_child(selected_label) + selected_label.custom_minimum_size = Vector2(320, 104) + selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + selected_row.add_child(selected_label) cell_info_label = Label.new() cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -1435,7 +1467,21 @@ func _update_hud() -> void: var selected := state.get_selected_unit() if selected.is_empty(): - selected_label.text = "No unit selected." + var hovered_unit := _hovered_unit_for_hud_portrait() + _update_hud_unit_portrait(hovered_unit) + if hovered_unit.is_empty(): + selected_label.text = "No unit selected." + else: + selected_label.text = "Hover: %s\n%s Lv.%d %s\nHP %d/%d MP %d/%d" % [ + str(hovered_unit.get("name", "Unit")), + str(hovered_unit.get("team", "")).capitalize(), + int(hovered_unit.get("level", 1)), + str(hovered_unit.get("class", "Unit")), + int(hovered_unit.get("hp", 0)), + int(hovered_unit.get("max_hp", 1)), + int(hovered_unit.get("mp", 0)), + int(hovered_unit.get("max_mp", 0)) + ] wait_button.disabled = true _update_tactic_button({}) _hide_tactic_menu() @@ -1444,6 +1490,7 @@ func _update_hud() -> void: _update_equip_button({}) _hide_equip_menu() else: + _update_hud_unit_portrait(selected) var roster_mark := " *" if selected.get("loaded_from_roster", false) else "" selected_label.text = "%s%s Lv.%d %s EXP %d\nHP %d/%d MP %d/%d ATK %d DEF %d INT %d\nMOV %d RNG %d-%d" % [ selected["name"], @@ -1483,6 +1530,31 @@ func _update_hud() -> void: threat_button.disabled = not battle_started or state.battle_status != BattleState.STATUS_ACTIVE or campaign_complete_screen +func _hovered_unit_for_hud_portrait() -> Dictionary: + if not state.is_inside(hover_cell): + return {} + return state.get_unit_at(hover_cell) + + +func _update_hud_unit_portrait(unit: Dictionary) -> void: + if hud_unit_portrait_panel == null: + return + if unit.is_empty(): + hud_unit_portrait_panel.visible = false + hud_unit_portrait_texture.texture = null + hud_unit_portrait_texture.visible = false + hud_unit_portrait_label.text = "" + hud_unit_portrait_label.visible = false + return + hud_unit_portrait_panel.visible = true + var texture := _load_portrait_texture(str(unit.get("portrait", ""))) + var has_portrait := texture != null + hud_unit_portrait_texture.texture = texture + hud_unit_portrait_texture.visible = has_portrait + hud_unit_portrait_label.text = _dialogue_portrait_initials(str(unit.get("name", ""))) + hud_unit_portrait_label.visible = not has_portrait + + func _update_cell_info() -> void: if not state.is_inside(hover_cell): cell_info_label.text = "Tile: -" @@ -2032,7 +2104,7 @@ func _apply_dialogue_side(side: String) -> void: func _update_dialogue_portrait(speaker: String, portrait_path: String) -> void: if dialogue_portrait_panel == null: return - var texture := _load_dialogue_portrait(portrait_path) + var texture := _load_portrait_texture(portrait_path) var has_portrait := texture != null var has_placeholder := not speaker.is_empty() dialogue_portrait_panel.visible = has_portrait or has_placeholder @@ -2042,12 +2114,14 @@ func _update_dialogue_portrait(speaker: String, portrait_path: String) -> void: dialogue_portrait_label.visible = not has_portrait and has_placeholder -func _load_dialogue_portrait(portrait_path: String) -> Texture2D: +func _load_portrait_texture(portrait_path: String) -> Texture2D: var normalized_path := portrait_path.strip_edges() if normalized_path.is_empty(): return null if not normalized_path.begins_with("res://") and not normalized_path.begins_with("user://"): normalized_path = "res://%s" % normalized_path + if missing_portrait_paths.has(normalized_path): + return null if portrait_texture_cache.has(normalized_path): return portrait_texture_cache[normalized_path] as Texture2D if ResourceLoader.exists(normalized_path, "Texture2D"): @@ -2056,9 +2130,11 @@ func _load_dialogue_portrait(portrait_path: String) -> Texture2D: portrait_texture_cache[normalized_path] = imported_texture return imported_texture if not FileAccess.file_exists(normalized_path): + missing_portrait_paths[normalized_path] = true return null var image := Image.new() if image.load(normalized_path) != OK: + missing_portrait_paths[normalized_path] = true return null var raw_texture := ImageTexture.create_from_image(image) portrait_texture_cache[normalized_path] = raw_texture diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index f4c3693..abb229c 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -430,6 +430,9 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H if (-not [string]::IsNullOrWhiteSpace($officerId) -and (-not $officerIds.Contains($officerId))) { Fail "Scenario $ScenarioId deployment $unitId references unknown officer: $officerId" } + if (Has-Prop $Deployment "portrait") { + Check-Portrait-Path (Get-Prop $Deployment "portrait" "") "Scenario $ScenarioId deployment $unitId portrait" + } if (Has-Prop $Deployment "requires_joined") { if (-not ((Get-Prop $Deployment "requires_joined" $false) -is [bool])) { Fail "Scenario $ScenarioId deployment $unitId requires_joined must be boolean."