Add photorealistic officer portraits

This commit is contained in:
2026-06-18 12:43:19 +09:00
parent c3a09d6577
commit 644bab582e
14 changed files with 61 additions and 7 deletions

View File

@@ -111,6 +111,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr
- Manual campaign checkpoint save/load.
- 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.
- Dialogue lines can place the portrait on the left or right side of the dialogue panel.
- 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.
@@ -125,7 +126,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr
2. Open this folder as a Godot project.
3. Run the project. The main scene is `res://scenes/battle_scene.tscn`.
Campaign order and chapter ranges live at `res://data/campaign/campaign.json`. Scenarios live under `res://data/scenarios/`, with reusable officers, classes, items, skills, and terrain definitions under `res://data/defs/`. Placeholder BGM and SFX live under `res://audio/`.
Campaign order and chapter ranges live at `res://data/campaign/campaign.json`. Scenarios live under `res://data/scenarios/`, with reusable officers, classes, items, skills, and terrain definitions under `res://data/defs/`. Generated portrait assets live under `res://art/portraits/`. Placeholder BGM and SFX live under `res://audio/`.
Data files can be checked with `powershell -NoProfile -ExecutionPolicy Bypass -File tools\validate_data.ps1`.
@@ -140,4 +141,4 @@ The project should grow from a small playable battle into a campaign tactics RPG
- Progression, equipment, and class depth third.
- Portraits, cutscenes, and campaign presentation after the core loop is fun.
The code keeps portraits and other visual assets replaceable so generated original assets can be added later without changing battle rules.
The code keeps portraits and other visual assets replaceable so generated original assets can be upgraded without changing battle rules.

BIN
art/portraits/cao_cao.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
art/portraits/cao_ren.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
art/portraits/dian_wei.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
art/portraits/guo_jia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
art/portraits/zhang_he.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -16,6 +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.
- `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.

View File

@@ -458,7 +458,7 @@ 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`, optional `portrait` resource paths such as `res://art/portraits/cao_cao.png`, and optional `side` values of `left` or `right` for portrait placement. If `side` is omitted, it defaults to `left`. 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_dialogue` plays once after victory and before the victory result panel opens. Lines may be plain strings or objects with `speaker`, non-empty `text`, optional `portrait` resource paths such as `res://art/portraits/cao_cao.png`, and optional `side` values of `left` or `right` for portrait placement. Portrait paths must stay inside the project, point to existing readable image files, and keep at least a 512x512 source resolution. If `side` is omitted, it defaults to `left`. 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.

View File

@@ -5,7 +5,7 @@ This project targets Godot 4.6 as the active editor baseline.
## Current Risk Profile
- The project is a simple Godot 4 `Node2D` application with data-driven JSON content.
- There are no addons, imported art assets, shaders, TileMaps, 3D physics scenes, or export presets yet.
- There are no addons, shaders, TileMaps, 3D physics scenes, or export presets yet. The first generated portrait PNGs are plain project files and can be loaded by the dialogue UI before editor import metadata exists.
- The current renderer is explicitly set to `gl_compatibility`, so new-project rendering defaults in Godot 4.6 should not silently change the existing project renderer.
- `project.godot` advertises `config/features=PackedStringArray("4.6")`.
- Godot 4.6 script UID sidecar files (`*.gd.uid`) are part of the migrated project metadata and should stay versioned.
@@ -62,6 +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`, with speaker initials only used 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.

View File

@@ -132,7 +132,7 @@
## Milestone 5: Presentation
- Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, and missing art falls back to speaker initials.
- 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`.
- 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.

View File

@@ -2006,9 +2006,16 @@ func _load_dialogue_portrait(portrait_path: String) -> Texture2D:
return null
if not normalized_path.begins_with("res://") and not normalized_path.begins_with("user://"):
normalized_path = "res://%s" % normalized_path
if not ResourceLoader.exists(normalized_path, "Texture2D"):
if ResourceLoader.exists(normalized_path, "Texture2D"):
var imported_texture := load(normalized_path) as Texture2D
if imported_texture != null:
return imported_texture
if not FileAccess.file_exists(normalized_path):
return null
return load(normalized_path) as Texture2D
var image := Image.new()
if image.load(normalized_path) != OK:
return null
return ImageTexture.create_from_image(image)
func _dialogue_portrait_initials(speaker: String) -> String:

View File

@@ -106,6 +106,14 @@ $validSkillEffectTypes = @("stat_bonus", "damage_over_time", "action_lock")
$validSkillAreaShapes = @("single", "diamond")
$validDialogueSides = @("left", "right")
$validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$'
$minimumPortraitDimension = 512
$portraitImageInspectorReady = $false
try {
Add-Type -AssemblyName System.Drawing -ErrorAction Stop
$portraitImageInspectorReady = $true
} catch {
$portraitImageInspectorReady = $false
}
$validMoveTypes = New-Object System.Collections.Generic.HashSet[string]
$knownFlagIds = New-Object System.Collections.Generic.HashSet[string]
$knownFlagValueKinds = @{}
@@ -1535,6 +1543,42 @@ function Check-Portrait-Path($Portrait, [string]$Context) {
if ([string]$Portrait -notmatch $validPortraitPathPattern) {
Fail "$Context must be a res:// image path."
}
$relativePath = ([string]$Portrait).Substring("res://".Length)
$rootPath = [System.IO.Path]::GetFullPath($Root)
$absolutePath = [System.IO.Path]::GetFullPath((Join-Path $rootPath $relativePath))
$trimChars = @([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$rootPathWithSeparator = $rootPath.TrimEnd($trimChars) + [System.IO.Path]::DirectorySeparatorChar
$insideRoot = $absolutePath.Equals($rootPath, [System.StringComparison]::OrdinalIgnoreCase) -or $absolutePath.StartsWith($rootPathWithSeparator, [System.StringComparison]::OrdinalIgnoreCase)
if (-not $insideRoot) {
Fail "$Context must stay inside the project: $Portrait"
}
if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) {
Fail "$Context references missing file: $Portrait"
}
Check-Portrait-Image $absolutePath ([string]$Portrait) $Context
}
function Check-Portrait-Image([string]$AbsolutePath, [string]$Portrait, [string]$Context) {
if (-not $portraitImageInspectorReady) {
Fail "$Context cannot inspect portrait image metadata: $Portrait"
}
$image = $null
$width = 0
$height = 0
try {
$image = [System.Drawing.Image]::FromFile($AbsolutePath)
$width = [int]$image.Width
$height = [int]$image.Height
} catch {
Fail "$Context references an unreadable image file: $Portrait"
} finally {
if ($null -ne $image) {
$image.Dispose()
}
}
if ($width -lt $minimumPortraitDimension -or $height -lt $minimumPortraitDimension) {
Fail "$Context image is too small: $Portrait ($width x $height, minimum ${minimumPortraitDimension}x${minimumPortraitDimension})"
}
}
function Check-Post-Battle-Dialogue($Dialogue, [string]$ScenarioId) {