From b8378d9144389fa8710a006de49fbaabade2f71a Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 03:55:46 +0900 Subject: [PATCH] Add dialogue portrait side placement --- README.md | 1 + data/scenarios/001_yellow_turbans.json | 12 +++++------ docs/ARCHITECTURE.md | 2 +- docs/DATA_MODEL.md | 6 +++--- docs/ROADMAP.md | 2 +- scripts/core/battle_state.gd | 12 +++++++++-- scripts/scenes/battle_scene.gd | 29 ++++++++++++++++++++++---- tools/validate_data.ps1 | 7 +++++++ 8 files changed, 54 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8deefde..35d60f8 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - New campaign save reset. - Victory and defeat result overlay. - Dialogue portrait slot with officer default image paths, optional per-line overrides, and speaker-initial fallback. +- 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, level-ups, and promotions. - Short unit slide animation for board movement. diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index c4ae3f6..5d3a245 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -95,8 +95,8 @@ { "type": "dialogue", "lines": [ - { "speaker": "Cao Cao", "text": "The rebels are scattered, but their numbers will swell if we hesitate." }, - { "speaker": "Xiahou Dun", "text": "Then we strike now. I will open a path through their front line." } + { "speaker": "Cao Cao", "side": "left", "text": "The rebels are scattered, but their numbers will swell if we hesitate." }, + { "speaker": "Xiahou Dun", "side": "right", "text": "Then we strike now. I will open a path through their front line." } ] } ] @@ -116,9 +116,9 @@ "join_officers": ["xiahou_yuan", "cao_ren"] }, "post_battle_dialogue": [ - { "speaker": "Xiahou Dun", "text": "The rebels are scattered. Yingchuan should breathe easier tonight." }, - { "speaker": "Cao Cao", "text": "This is only the first fire. If the court cannot move quickly, we must." }, - { "speaker": "Xiahou Yuan", "text": "Then call on us for the next campaign. We will ride with you." }, - { "speaker": "Cao Ren", "text": "The clan stands ready, my lord." } + { "speaker": "Xiahou Dun", "side": "right", "text": "The rebels are scattered. Yingchuan should breathe easier tonight." }, + { "speaker": "Cao Cao", "side": "left", "text": "This is only the first fire. If the court cannot move quickly, we must." }, + { "speaker": "Xiahou Yuan", "side": "right", "text": "Then call on us for the next campaign. We will ride with you." }, + { "speaker": "Cao Ren", "side": "right", "text": "The clan stands ready, my lord." } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ef7aca..8696c74 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,6 +58,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. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory 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 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, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved 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. Already completed scenarios can be replayed without granting duplicate rewards, choices, or replay inventory consumption; pre-battle Shop, Armory, Roster, and Formation 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` campaign order and title data with `BattleState` briefing and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels, board units render low-HP warning rings and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, `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` campaign order and title data with `BattleState` briefing and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, `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 d7bc939..c1aa33d 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 `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. 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. -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`. +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, portrait default rules, and optional `side` placement as `post_battle_dialogue`. ```json { @@ -413,7 +413,7 @@ Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, and move { "type": "dialogue", "lines": [ - { "speaker": "Cao Cao", "portrait": "res://art/portraits/cao_cao.png", "text": "Hold formation." } + { "speaker": "Cao Cao", "side": "left", "portrait": "res://art/portraits/cao_cao.png", "text": "Hold formation." } ] }, { "type": "set_objective", "victory": "Defeat the vanguard and reinforcements." }, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ce47420..33aadf0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -134,7 +134,7 @@ - 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. -- Chapter UI and visual novel scenes. Pre-battle briefing now has a first campaign battle header; chapter-aware numbering remains planned once chapter data exists. +- Chapter UI and visual novel scenes. Pre-battle briefing now has a first campaign battle header, and dialogue lines can choose left or right portrait placement; chapter-aware numbering remains planned once chapter data exists. ## Milestone 6: Content Expansion diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 1aff96d..1dbd412 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -2487,7 +2487,7 @@ func _normalize_dialogue_line(line) -> Dictionary: var text := str(line) if text.is_empty(): return {} - return {"speaker": "", "text": text, "portrait": ""} + return {"speaker": "", "text": text, "portrait": "", "side": "left"} if typeof(line) == TYPE_DICTIONARY: var text := str(line.get("text", "")) if text.is_empty(): @@ -2499,11 +2499,19 @@ func _normalize_dialogue_line(line) -> Dictionary: return { "speaker": speaker, "text": text, - "portrait": portrait + "portrait": portrait, + "side": _normalize_dialogue_side(line.get("side", "left")) } return {} +func _normalize_dialogue_side(value) -> String: + var side := str(value).strip_edges().to_lower() + if side == "right": + return "right" + return "left" + + func _spawn_event_deployment(deployment) -> bool: if typeof(deployment) != TYPE_DICTIONARY: push_error("Skipping malformed event deployment in %s." % battle_id) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 2e460a7..93e1c01 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -86,10 +86,12 @@ var formation_menu: VBoxContainer var formation_list: VBoxContainer var formation_status_label: Label var formation_unit_id := "" +var dialogue_row: HBoxContainer var dialogue_panel: PanelContainer var dialogue_portrait_panel: PanelContainer var dialogue_portrait_texture: TextureRect var dialogue_portrait_label: Label +var dialogue_column: VBoxContainer var dialogue_speaker_label: Label var dialogue_text_label: Label var dialogue_continue_button: Button @@ -473,7 +475,7 @@ func _create_hud() -> void: dialogue_panel.size = Vector2(760, 140) root.add_child(dialogue_panel) - var dialogue_row := HBoxContainer.new() + dialogue_row = HBoxContainer.new() dialogue_row.add_theme_constant_override("separation", 14) dialogue_panel.add_child(dialogue_row) @@ -497,7 +499,7 @@ func _create_hud() -> void: dialogue_portrait_label.add_theme_font_size_override("font_size", 22) dialogue_portrait_stack.add_child(dialogue_portrait_label) - var dialogue_column := VBoxContainer.new() + dialogue_column = VBoxContainer.new() dialogue_column.custom_minimum_size = Vector2(620, 112) dialogue_column.add_theme_constant_override("separation", 8) dialogue_row.add_child(dialogue_column) @@ -1538,16 +1540,24 @@ func _normalized_dialogue_lines(lines: Array) -> Array: result.append({ "speaker": str(line.get("speaker", "")), "text": text, - "portrait": str(line.get("portrait", "")) + "portrait": str(line.get("portrait", "")), + "side": _normalized_dialogue_side(line.get("side", "left")) }) elif typeof(line) == TYPE_STRING: var text := str(line) if text.is_empty(): continue - result.append({"speaker": "", "text": text, "portrait": ""}) + result.append({"speaker": "", "text": text, "portrait": "", "side": "left"}) return result +func _normalized_dialogue_side(value) -> String: + var side := str(value).strip_edges().to_lower() + if side == "right": + return "right" + return "left" + + func _can_show_dialogue_now() -> bool: if not battle_started or campaign_complete_screen: return false @@ -1582,6 +1592,8 @@ func _render_dialogue_line() -> void: return var line: Dictionary = active_dialogue_lines[active_dialogue_index] var speaker := str(line.get("speaker", "")) + var side := _normalized_dialogue_side(line.get("side", "left")) + _apply_dialogue_side(side) dialogue_speaker_label.text = speaker dialogue_speaker_label.visible = not speaker.is_empty() dialogue_text_label.text = str(line.get("text", "")) @@ -1592,6 +1604,15 @@ func _render_dialogue_line() -> void: queue_redraw() +func _apply_dialogue_side(side: String) -> void: + if dialogue_row == null or dialogue_portrait_panel == null or dialogue_column == null: + return + var portrait_index := 1 if side == "right" else 0 + dialogue_row.move_child(dialogue_portrait_panel, portrait_index) + dialogue_speaker_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT + dialogue_text_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT + + func _update_dialogue_portrait(speaker: String, portrait_path: String) -> void: if dialogue_portrait_panel == null: return diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index 83615d3..a24498f 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") +$validDialogueSides = @("left", "right") $validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$' $knownFlagIds = New-Object System.Collections.Generic.HashSet[string] $knownFlagValueKinds = @{} @@ -1207,6 +1208,12 @@ function Check-Dialogue-Line($Line, [string]$Context) { if (Has-Prop $Line "portrait") { Check-Portrait-Path (Get-Prop $Line "portrait" "") "$Context portrait" } + if (Has-Prop $Line "side") { + $side = ([string](Get-Prop $Line "side" "")).Trim().ToLowerInvariant() + if (-not $validDialogueSides.Contains($side)) { + Fail "$Context has unknown dialogue side: $side" + } + } } function Check-Portrait-Path($Portrait, [string]$Context) {