Add officer portrait defaults

This commit is contained in:
2026-06-18 03:45:39 +09:00
parent 2166ea51ef
commit 1b264081ea
8 changed files with 73 additions and 18 deletions

View File

@@ -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.

View File

@@ -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 },

View File

@@ -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.

View File

@@ -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
{

View File

@@ -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.

View File

@@ -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 {}

View File

@@ -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:

View File

@@ -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"
}
}
}
}