From 1b264081eadde46286b9cab77c0384b8b227e189 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 03:45:39 +0900 Subject: [PATCH] Add officer portrait defaults --- README.md | 2 +- data/defs/officers.json | 7 +++++ docs/ARCHITECTURE.md | 2 +- docs/DATA_MODEL.md | 4 +-- docs/ROADMAP.md | 2 +- scripts/core/battle_state.gd | 8 ++++-- scripts/core/data_catalog.gd | 15 +++++++++++ tools/validate_data.ps1 | 51 ++++++++++++++++++++++++++++-------- 8 files changed, 73 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a254aa8..d1b9d03 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Campaign completion screen. - New campaign save reset. - Victory and defeat result overlay. -- Dialogue portrait slot with optional image loading and speaker-initial fallback. +- Dialogue portrait slot with officer default image paths, optional per-line overrides, and speaker-initial fallback. - Placeholder menu/battle BGM plus UI, movement, attack, tactic, item, victory, and defeat SFX. - Floating combat text for damage, recovery, misses, support effects, level-ups, and promotions. - Short unit slide animation for board movement. diff --git a/data/defs/officers.json b/data/defs/officers.json index 6a945c4..16393e6 100644 --- a/data/defs/officers.json +++ b/data/defs/officers.json @@ -3,6 +3,7 @@ "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": 10, "def": 7, "int": 12, "agi": 8 }, @@ -13,6 +14,7 @@ "name": "Xiahou Dun", "faction": "wei", "class_id": "cavalry", + "portrait": "res://art/portraits/xiahou_dun.png", "level": 1, "exp": 0, "base": { "hp": 40, "mp": 4, "atk": 11, "def": 6, "int": 5, "agi": 9 }, @@ -23,6 +25,7 @@ "name": "Xiahou Yuan", "faction": "wei", "class_id": "archer", + "portrait": "res://art/portraits/xiahou_yuan.png", "level": 1, "exp": 0, "base": { "hp": 32, "mp": 4, "atk": 9, "def": 5, "int": 6, "agi": 12 }, @@ -33,6 +36,7 @@ "name": "Cao Ren", "faction": "wei", "class_id": "infantry", + "portrait": "res://art/portraits/cao_ren.png", "level": 1, "exp": 0, "base": { "hp": 36, "mp": 3, "atk": 10, "def": 8, "int": 5, "agi": 7 }, @@ -43,6 +47,7 @@ "name": "Dian Wei", "faction": "wei", "class_id": "warrior", + "portrait": "res://art/portraits/dian_wei.png", "level": 3, "exp": 0, "base": { "hp": 42, "mp": 2, "atk": 13, "def": 8, "int": 3, "agi": 7 }, @@ -53,6 +58,7 @@ "name": "Guo Jia", "faction": "wei", "class_id": "strategist", + "portrait": "res://art/portraits/guo_jia.png", "level": 8, "exp": 0, "base": { "hp": 30, "mp": 18, "atk": 5, "def": 5, "int": 14, "agi": 9 }, @@ -63,6 +69,7 @@ "name": "Zhang He", "faction": "wei", "class_id": "elite_cavalry", + "portrait": "res://art/portraits/zhang_he.png", "level": 12, "exp": 0, "base": { "hp": 48, "mp": 6, "atk": 15, "def": 10, "int": 7, "agi": 14 }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 01701e7..ddd04bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,7 +23,7 @@ ## Data Files - `data/defs/classes.json`: Class stats, movement types, skill access. -- `data/defs/officers.json`: Named officer base stats and growth. +- `data/defs/officers.json`: Named officer base stats, growth, and default portrait paths. - `data/defs/items.json`: Weapons, armor, accessories, and consumables. - `data/defs/skills.json`: MP tactic definitions for damage, healing, target rules, and range. - `data/defs/terrain.json`: Terrain colors, defense, avoid, and movement costs. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 00d8db0..d7bc939 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -392,11 +392,11 @@ Conditions may include `after_event` to stay inactive until a one-shot event has `unit_reaches_tile` checks living deployed units against a zero-based `pos` coordinate. Use `officer_ids` for persistent named officers or `unit_ids` for scenario-specific units; the validator requires one of those filters so destination objectives do not accidentally trigger from any unit. Victory destination cells are drawn as objective markers on the battle map. -`post_battle_dialogue` plays once after victory and before the victory result panel opens. Lines may be plain strings or objects with `speaker`, non-empty `text`, and optional `portrait` resource paths such as `res://art/portraits/cao_cao.png`. If no usable portrait is loaded, the battle scene falls back to a speaker-initial panel. +`post_battle_dialogue` plays once after victory and before the victory result panel opens. Lines may be plain strings or objects with `speaker`, non-empty `text`, and optional `portrait` resource paths such as `res://art/portraits/cao_cao.png`. If `portrait` is omitted, `BattleState` uses a matching officer definition's default portrait path when the line's `speaker` matches the officer `name`. If no usable portrait is loaded, the battle scene falls back to a speaker-initial panel. `post_battle_choices` are shown on the victory result panel after first-time scenario rewards are applied. Each choice needs a unique stable lowercase `id`, a non-empty `label`, and a `set_flags` object whose keys are stable lowercase flag ids. Choices may also grant positive `gold`, known `items`, `join_officers`, and `leave_officers`. When rewards are saved but the player has not selected a choice yet, `CampaignState.pending_post_battle_choice_scenario_id` records that scenario id so reloading the game returns to the victory choice panel instead of skipping the branch. The selected choice is saved to `CampaignState.flags`, `CampaignState.applied_post_battle_choices`, and campaign membership only when the player presses its result-panel button; completed-scenario replays do not show choices again. Save-load migration for older saves uses flags as a conservative choice signal, so avoid designing multiple choices in one scenario that can all match the same saved flag state. -Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, and movement-triggered `unit_reaches_tile` triggers. `battle_begin` runs after the briefing is closed. `unit_reaches_tile` events run when a moved unit enters the zero-based `pos` cell; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. Actions currently include `log`, `dialogue`, `set_objective`, `spawn_deployment`, and `spawn_deployments`. +Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, and movement-triggered `unit_reaches_tile` triggers. `battle_begin` runs after the briefing is closed. `unit_reaches_tile` events run when a moved unit enters the zero-based `pos` cell; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. Actions currently include `log`, `dialogue`, `set_objective`, `spawn_deployment`, and `spawn_deployments`. Event `dialogue` actions use the same dialogue line format and portrait default rules as `post_battle_dialogue`. ```json { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d620a7c..75ce0c5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -130,7 +130,7 @@ ## Milestone 5: Presentation -- Portrait pipeline. Dialogue lines can carry optional portrait image paths, with speaker-initial fallback when art is missing. +- Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, and missing art falls back to speaker initials. - Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items. - 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 38c307c..1aff96d 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -2492,10 +2492,14 @@ func _normalize_dialogue_line(line) -> Dictionary: var text := str(line.get("text", "")) if text.is_empty(): return {} + var speaker := str(line.get("speaker", "")) + var portrait := str(line.get("portrait", "")) + if portrait.is_empty(): + portrait = data_catalog.get_portrait_for_speaker(speaker) return { - "speaker": str(line.get("speaker", "")), + "speaker": speaker, "text": text, - "portrait": str(line.get("portrait", "")) + "portrait": portrait } return {} diff --git a/scripts/core/data_catalog.gd b/scripts/core/data_catalog.gd index 6fe8784..707b50c 100644 --- a/scripts/core/data_catalog.gd +++ b/scripts/core/data_catalog.gd @@ -110,6 +110,17 @@ func get_class_def(class_id: String) -> Dictionary: return _get_dict(classes, class_id).duplicate(true) +func get_portrait_for_speaker(speaker_name: String) -> String: + var normalized_speaker := _normalized_speaker_name(speaker_name) + if normalized_speaker.is_empty(): + return "" + for officer_id in officers.keys(): + var officer := _get_dict(officers, officer_id) + if _normalized_speaker_name(str(officer.get("name", ""))) == normalized_speaker: + return str(officer.get("portrait", "")) + return "" + + func _load_json_object(path: String) -> Dictionary: if not FileAccess.file_exists(path): push_error("Missing data file: %s" % path) @@ -132,6 +143,10 @@ func _get_dict(source: Dictionary, key) -> Dictionary: return {} +func _normalized_speaker_name(value: String) -> String: + return value.strip_edges().to_lower() + + func _merged_equipment(base_equipment, override_equipment) -> Dictionary: var result := {} if typeof(base_equipment) == TYPE_DICTIONARY: diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index d4bf4cc..83615d3 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -101,6 +101,7 @@ $validSkillKinds = @("damage", "heal", "support") $validSkillTargets = @("enemy", "ally", "self", "any") $validSkillStats = @("atk", "def", "int", "agi") $validSkillEffectTypes = @("stat_bonus") +$validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$' $knownFlagIds = New-Object System.Collections.Generic.HashSet[string] $knownFlagValueKinds = @{} $joinedOfficerIds = New-Object System.Collections.Generic.HashSet[string] @@ -1204,17 +1205,19 @@ function Check-Dialogue-Line($Line, [string]$Context) { Fail "$Context has an empty dialogue text." } if (Has-Prop $Line "portrait") { - $portrait = Get-Prop $Line "portrait" "" - if ($portrait -is [System.Array] -or $portrait -is [System.Management.Automation.PSCustomObject]) { - Fail "$Context portrait must be a string resource path." - } - if ([string]::IsNullOrWhiteSpace([string]$portrait)) { - Fail "$Context has an empty portrait path." - } - $portraitText = [string]$portrait - if ($portraitText -notmatch '^res://.+\.(png|jpg|jpeg|webp)$') { - Fail "$Context portrait must be a res:// image path." - } + Check-Portrait-Path (Get-Prop $Line "portrait" "") "$Context portrait" + } +} + +function Check-Portrait-Path($Portrait, [string]$Context) { + if ($Portrait -is [System.Array] -or $Portrait -is [System.Management.Automation.PSCustomObject]) { + Fail "$Context must be a string resource path." + } + if ([string]::IsNullOrWhiteSpace([string]$Portrait)) { + Fail "$Context must not be empty." + } + if ([string]$Portrait -notmatch $validPortraitPathPattern) { + Fail "$Context must be a res:// image path." } } @@ -1269,9 +1272,21 @@ foreach ($classProp in $classes.PSObject.Properties) { Check-Skill-Refs (Get-Prop $classProp.Value "skills" @()) "Class $($classProp.Name)" } +$officerNames = New-Object System.Collections.Generic.HashSet[string] foreach ($officerProp in $officers.PSObject.Properties) { + $officerName = [string](Get-Prop $officerProp.Value "name" "") + if ([string]::IsNullOrWhiteSpace($officerName)) { + Fail "Officer $($officerProp.Name) has empty name." + } + $normalizedOfficerName = $officerName.Trim().ToLowerInvariant() + if (-not $officerNames.Add($normalizedOfficerName)) { + Fail "Officer $($officerProp.Name) has duplicate name: $officerName" + } Check-Skill-Refs (Get-Prop $officerProp.Value "skills" @()) "Officer $($officerProp.Name)" Check-Officer-Equipment $officerProp.Name $officerProp.Value + if (Has-Prop $officerProp.Value "portrait") { + Check-Portrait-Path (Get-Prop $officerProp.Value "portrait" "") "Officer $($officerProp.Name) portrait" + } } foreach ($scenario in $campaign.scenarios) { @@ -1357,6 +1372,20 @@ foreach ($scenario in $campaign.scenarios) { Check-Deployment $deployment $scenarioId $width $height $rows $seenIds } } + if ($actionType -eq "dialogue") { + if (Has-Prop $action "lines") { + $rawLines = Get-Prop $action "lines" $null + $lines = @($rawLines) + if ($null -eq $rawLines -or $lines.Count -le 0) { + Fail "Scenario $scenarioId event $eventId dialogue action has malformed lines." + } + foreach ($line in $lines) { + Check-Dialogue-Line $line "Scenario $scenarioId event $eventId dialogue" + } + } else { + Check-Dialogue-Line $action "Scenario $scenarioId event $eventId dialogue" + } + } } }