diff --git a/README.md b/README.md index e73fc3d..4d864eb 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Weapon, armor, and accessory rewards can be stored, displayed, compared, equipped, and unequipped from the battle HUD; some weapons deal bonus physical damage against matching move types. - First named equipment rewards exist in the northern campaign arc and show a `[Named]` or `Named` tag in reward, inventory, shop, Armory, and equipment menus. - Scenario-specific pre-battle shop purchases, optional finite shop stock, and 50% sell-back save immediately and sync into the upcoming battle inventory. -- Pre-battle Talk can play scenario camp dialogue, and shop stock can include merchant flavor lines before a battle. +- Pre-battle Talk can list scenario camp conversations by officer or topic, and shop stock can include merchant flavor lines before a battle. - Pre-battle Armory equipment changes, including unequipping gear back into stock, save immediately and sync roster equipment plus inventory stock. - Pre-battle Roster can move optional officers between sortie and reserve within scenario limits. - Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells. diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index 0a7c367..f4f31de 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -22,6 +22,42 @@ "camp_dialogue": [ { "speaker": "Cao Cao", "side": "left", "text": "Yingchuan is a spark in dry grass. If we move decisively, the court may still believe order can return." }, { "speaker": "Xiahou Dun", "side": "right", "text": "The men are ready. Give me the front, and I will keep the rebels fixed while you cut their nerve." } + ], + "camp_conversations": [ + { + "id": "cao_cao_strategy", + "group": "officer", + "officer_id": "cao_cao", + "label": "Cao Cao - Strategy", + "speaker": "Cao Cao", + "summary": "Read the revolt before the first move.", + "lines": [ + { "speaker": "Cao Cao", "side": "left", "text": "Yingchuan is a spark in dry grass. If we move decisively, the court may still believe order can return." }, + { "speaker": "Cao Cao", "side": "left", "text": "Their center is loose. Break the front, then deny them time to gather more banners." } + ] + }, + { + "id": "xiahou_dun_vanguard", + "group": "officer", + "officer_id": "xiahou_dun", + "label": "Xiahou Dun - Vanguard", + "speaker": "Xiahou Dun", + "summary": "Discuss the first clash.", + "lines": [ + { "speaker": "Xiahou Dun", "side": "right", "text": "The men are ready. Give me the front, and I will keep the rebels fixed while you cut their nerve." }, + { "speaker": "Cao Cao", "side": "left", "text": "Hold them in place, but do not chase too deep. We still need eyes on the woods." } + ] + }, + { + "id": "northern_woods_cache", + "group": "topic", + "label": "Northern Woods Cache", + "summary": "Follow up on the merchant's rumor.", + "lines": [ + { "speaker": "Cao Cao", "side": "left", "text": "The merchant's rumor may be useful. If the line holds, send a scout through the northern woods." }, + { "speaker": "Xiahou Dun", "side": "right", "text": "A hidden cache in rebel country? Then we take it before they remember where they left it." } + ] + } ] }, "shop": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9eec805..dcc4cbe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,7 +36,7 @@ 1. Load battle data. 2. Hydrate deployments through `DataCatalog`. 3. Dispatch battle-start scenario events. -4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, camp Talk dialogue, manual checkpoint menu, and optional pre-battle shop, Armory, Roster, and Formation setup. +4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, selectable camp Talk conversations, manual checkpoint menu, and optional pre-battle shop, Armory, Roster, and Formation setup. 5. Player selects a unit. 6. Briefing completion dispatches battle-begin events, including opening dialogue. 7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, a short attack motion, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, hover unit class/movement/equipment details, and floating combat text for core action and UI feedback. @@ -57,7 +57,7 @@ Joined officers gate whether `requires_joined` player deployments are loaded at all. Rewards and post-battle choices can add or remove joined officers without deleting their saved roster snapshot, so a later rejoin can preserve progression. When a save loads, `CampaignState` scans completed scenarios in campaign order and reapplies only officer join/leave transitions from rewards and the saved `applied_post_battle_choices` ledger, allowing added recruitment rewards to reach older saves without replaying gold or items. Old saves without the ledger can backfill a choice id only when exactly one completed scenario choice matches saved flags. Roster entries are keyed by `officer_id` when available, so future scenarios can deploy the same joined officer under different scenario-specific `unit_id` values. Saved progression overlays final battle stats for now; a later equipment refactor should split base stats from equipment bonuses before adding item leveling or stat recalculation. New battles currently start player units healed to their saved maximum HP/MP. Skill access is carried in roster snapshots, with class defaults still applied for old saves that do not include skills. Promoted class ids are saved in the roster, and the next battle rehydrates class-derived movement, growth, skills, and range from the saved class id before equipment range is recalculated. -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, can expose `briefing.camp_dialogue` through the Talk button, 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, and `shop.merchant` lines are presentation-only camp flavor. 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, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. 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, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. 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; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, 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, event gold pickups, 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. +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, can expose `briefing.camp_conversations` through a selectable Talk menu with `briefing.camp_dialogue` as a fallback, 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, and `shop.merchant` lines are presentation-only camp flavor. 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, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. 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, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. 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; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, 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, event gold pickups, 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, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, 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 or class sprites for the selected unit or hovered unit, board maps can draw a scenario background texture under translucent terrain overlays and terrain detail patterns, board units render class sprite tokens, team rings, class badges, low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, item buttons can show optional cached item icons, 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, objective updates, AI target-focus changes, and item/gold pickup effects use distinct feedback, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, `BattleState.unit_action_motion_requested` asks the scene to render a short attack motion, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index e79f946..a4814e5 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -359,7 +359,7 @@ Scenario `formation.cells` controls the highlighted starting cells available dur ## Scenarios -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, camp dialogue, visible objective text, battle conditions, map background art, and victory rewards. +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, selectable camp conversations, visible objective text, battle conditions, map background art, 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 `portrait`, `sprite`, and `equipment`; the validator checks image files and equipment against class equipment slots, and `DataCatalog` applies their presentation paths, stat bonuses, and weapon ranges when hydrating the unit. @@ -382,6 +382,28 @@ Scenario deployments may override `move_type` for special units. For example, a "camp_dialogue": [ { "speaker": "Cao Cao", "side": "left", "text": "We settle this before the revolt spreads." } ], + "camp_conversations": [ + { + "id": "cao_cao_strategy", + "group": "officer", + "officer_id": "cao_cao", + "label": "Cao Cao - Strategy", + "speaker": "Cao Cao", + "summary": "Read the revolt before the first move.", + "lines": [ + { "speaker": "Cao Cao", "side": "left", "text": "We end this before the revolt becomes a blaze." } + ] + }, + { + "id": "northern_woods_cache", + "group": "topic", + "label": "Northern Woods Cache", + "summary": "Follow up on the merchant's rumor.", + "lines": [ + { "speaker": "Cao Cao", "side": "left", "text": "Send a scout through the northern woods if the line holds." } + ] + } + ], "conditional_lines": [ { "campaign_flags": { "pursued_dong_zhuo": true }, @@ -463,6 +485,8 @@ Scenario deployments may override `move_type` for special units. For example, a `objectives` are display text. `conditions` drive the actual victory and defeat checks. Defeat is evaluated before victory, matching Cao Cao Zhuan-style commander-loss rules. If `conditions` is omitted, battles fall back to defeating all enemies for victory and losing all player units for defeat. +`briefing.camp_conversations` drives the pre-battle Talk menu. Each entry needs a stable lowercase `id`, a non-empty `label`, a `group` of `officer` or `topic`, and non-empty dialogue `lines`. Officer conversations also need an `officer_id` that exists in `officers.json`; `BattleState` normalizes their dialogue lines so speaker portraits can be filled from officer defaults. If `camp_conversations` is absent, the older `briefing.camp_dialogue` array is still exposed as one fallback War Council conversation. + `BattleState` also exposes condition progress and defeat-risk text for presentation. The active HUD and briefing panels split objective, progress, and risk details while showing defeated enemy counts, destination reach status, named commander or protected-unit safety, and turns remaining from the same `conditions` data. Conditions gated by `after_event` stay marked as pending; when the referenced event is a `unit_reaches_tile` or `turn_start` trigger, the tracker can surface that prerequisite as the next visible objective, and `unit_reaches_tile` prerequisites can also draw objective markers while the gated condition is still locked. Player deployments may set `requires_joined: true`; those units are only loaded if their `officer_id` is in campaign `joined_officers`. Scenario rewards can add officers through `rewards.join_officers` and remove them through `rewards.leave_officers`. Post-battle choices can also use `join_officers` and `leave_officers` for branch-specific membership changes. `data/campaign/campaign.json` seeds a new campaign with `initial_joined_officers`. @@ -627,4 +651,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, cure statuses, debuff cleanse effects, class sprites, item icons, map backgrounds, officer and deployment portraits/sprites, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event trigger unit/officer filters, event item/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` coordinates, campaign flag references, joined-officer gates, briefing line blocks, camp dialogue, deployment ids, deployment bounds, impassable spawn cells, shop stock, shop merchant lines, 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, cure statuses, debuff cleanse effects, class sprites, item icons, map backgrounds, officer and deployment portraits/sprites, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event trigger unit/officer filters, event item/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` coordinates, campaign flag references, joined-officer gates, briefing line blocks, camp dialogue and camp conversations, deployment ids, deployment bounds, impassable spawn cells, shop stock, shop merchant lines, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4c162bd..2bc3f08 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,7 +46,7 @@ - Mid-battle map/action events have a first action system with movement ambushes and scripted unit withdrawals. - Opening battle dialogue exists through event actions. - Post-battle dialogue exists before the victory result panel. -- Pre-battle camp dialogue can be launched from the briefing Talk button, and shops can show merchant flavor lines. +- Pre-battle camp conversations can be selected from the briefing Talk menu by officer or topic, and shops can show merchant flavor lines. - Reinforcements, scripted unit withdrawals, objective changes with HUD/log feedback, and item/gold pickup events exist in a basic form. - Post-battle reward save, campaign choice flags, first flag-driven briefing/shop/event branches, sectioned result summaries, and choice reward previews exist in a basic form. Richer reward screens and wider branch support are still planned. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index bd90184..13d0421 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -243,10 +243,42 @@ func _normalized_briefing(source) -> Dictionary: for line in block.get("lines", []): lines.append(str(line)) result["lines"] = lines + result["camp_dialogue"] = _normalized_dialogue_lines(result.get("camp_dialogue", [])) + result["camp_conversations"] = _normalized_camp_conversations(result.get("camp_conversations", [])) result.erase("conditional_lines") return result +func _normalized_camp_conversations(source) -> Array: + var result := [] + if typeof(source) != TYPE_ARRAY: + return result + var seen := {} + for entry in source: + if typeof(entry) != TYPE_DICTIONARY: + continue + var conversation_id := str(entry.get("id", "")).strip_edges() + if conversation_id.is_empty() or seen.has(conversation_id): + continue + var lines := _normalized_dialogue_lines(entry.get("lines", [])) + if lines.is_empty(): + continue + seen[conversation_id] = true + var label := str(entry.get("label", conversation_id.capitalize())).strip_edges() + if label.is_empty(): + label = conversation_id.capitalize() + result.append({ + "id": conversation_id, + "group": str(entry.get("group", "topic")).strip_edges(), + "officer_id": str(entry.get("officer_id", "")).strip_edges(), + "label": label, + "speaker": str(entry.get("speaker", "")).strip_edges(), + "summary": str(entry.get("summary", "")).strip_edges(), + "lines": lines + }) + return result + + func _is_condition_shape_valid(condition) -> bool: return typeof(condition) == TYPE_DICTIONARY or typeof(condition) == TYPE_ARRAY diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index b108b23..a575098 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -98,6 +98,9 @@ var chapter_list: VBoxContainer var chapter_status_label: Label var shop_button: Button var talk_button: Button +var talk_menu: VBoxContainer +var talk_list: VBoxContainer +var talk_status_label: Label var shop_menu: VBoxContainer var shop_buy_button: Button var shop_sell_button: Button @@ -552,6 +555,25 @@ func _create_hud() -> void: shop_list.add_theme_constant_override("separation", 4) shop_scroll.add_child(shop_list) + talk_menu = VBoxContainer.new() + talk_menu.visible = false + talk_menu.add_theme_constant_override("separation", 6) + briefing_column.add_child(talk_menu) + + talk_status_label = Label.new() + talk_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + talk_status_label.custom_minimum_size = Vector2(600, 22) + talk_menu.add_child(talk_status_label) + + var talk_scroll := ScrollContainer.new() + talk_scroll.custom_minimum_size = Vector2(600, 150) + talk_menu.add_child(talk_scroll) + + talk_list = VBoxContainer.new() + talk_list.custom_minimum_size = Vector2(580, 0) + talk_list.add_theme_constant_override("separation", 4) + talk_scroll.add_child(talk_list) + armory_menu = VBoxContainer.new() armory_menu.visible = false armory_menu.add_theme_constant_override("separation", 6) @@ -2681,6 +2703,7 @@ func _load_scenario(scenario_id: String) -> void: _hide_equip_menu() _hide_chapter_menu() _hide_shop_menu() + _hide_talk_menu() _hide_armory_menu() _hide_roster_menu() _hide_formation_menu() @@ -2812,6 +2835,7 @@ func _show_briefing() -> void: battle_started = false _hide_chapter_menu() _hide_shop_menu() + _hide_talk_menu() _hide_armory_menu() _hide_roster_menu() _hide_formation_menu() @@ -2822,7 +2846,7 @@ func _show_briefing() -> void: if shop_button != null: shop_button.disabled = prep_locked or (state.get_shop_item_ids().is_empty() and _get_sellable_item_ids().is_empty()) if talk_button != null: - talk_button.disabled = prep_locked or _camp_dialogue_lines().is_empty() + talk_button.disabled = prep_locked or _camp_conversation_entries().is_empty() if armory_button != null: armory_button.disabled = prep_locked or state.get_controllable_player_units().is_empty() if roster_button != null: @@ -2845,6 +2869,7 @@ func _start_battle_from_briefing() -> void: briefing_panel.visible = false _hide_chapter_menu() _hide_shop_menu() + _hide_talk_menu() _hide_armory_menu() _hide_roster_menu() _hide_formation_menu() @@ -2862,6 +2887,7 @@ func _on_chapter_pressed() -> void: _hide_chapter_menu() else: _hide_shop_menu() + _hide_talk_menu() _hide_armory_menu() _hide_roster_menu() _hide_formation_menu() @@ -2969,6 +2995,7 @@ func _on_shop_pressed() -> void: else: shop_sell_mode = state.get_shop_item_ids().is_empty() and not _get_sellable_item_ids().is_empty() _hide_chapter_menu() + _hide_talk_menu() _hide_armory_menu() _hide_roster_menu() _hide_formation_menu() @@ -2987,27 +3014,104 @@ func _hide_shop_menu() -> void: func _on_talk_pressed() -> void: if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible: return - var lines := _camp_dialogue_lines() - if lines.is_empty(): + if _camp_conversation_entries().is_empty(): _play_ui_cancel() return - _play_ui_confirm() - _hide_chapter_menu() - _hide_shop_menu() - _hide_armory_menu() - _hide_roster_menu() - _hide_formation_menu() - _hide_save_menu() - _show_camp_dialogue(lines) + _play_ui_click() + if talk_menu != null and talk_menu.visible: + _hide_talk_menu() + else: + _hide_chapter_menu() + _hide_shop_menu() + _hide_armory_menu() + _hide_roster_menu() + _hide_formation_menu() + _hide_save_menu() + _rebuild_talk_menu() + talk_menu.visible = true _update_hud() -func _camp_dialogue_lines() -> Array: +func _hide_talk_menu() -> void: + if talk_menu == null: + return + talk_menu.visible = false + + +func _rebuild_talk_menu() -> void: + if talk_list == null: + return + _clear_talk_list() + var conversations := _camp_conversation_entries() + if talk_status_label != null: + talk_status_label.text = "Camp conversations | %d available" % conversations.size() + if conversations.is_empty(): + var empty_label := Label.new() + empty_label.text = "No camp conversations" + talk_list.add_child(empty_label) + return + for conversation in conversations: + var conversation_id := str(conversation.get("id", "")) + var button := Button.new() + button.text = _format_camp_conversation_button_text(conversation) + button.pressed.connect(_on_camp_conversation_pressed.bind(conversation_id)) + talk_list.add_child(button) + + +func _clear_talk_list() -> void: + for child in talk_list.get_children(): + talk_list.remove_child(child) + child.queue_free() + + +func _camp_conversation_entries() -> Array: var briefing := state.get_briefing() + var conversations = briefing.get("camp_conversations", []) + if typeof(conversations) == TYPE_ARRAY and not conversations.is_empty(): + return conversations var lines = briefing.get("camp_dialogue", []) - if typeof(lines) != TYPE_ARRAY: - return [] - return lines + if typeof(lines) == TYPE_ARRAY and not lines.is_empty(): + return [{ + "id": "camp_dialogue", + "group": "topic", + "officer_id": "", + "label": "War Council", + "speaker": "", + "summary": "Review the immediate situation.", + "lines": lines + }] + return [] + + +func _format_camp_conversation_button_text(conversation: Dictionary) -> String: + var label := str(conversation.get("label", "Camp Talk")) + var speaker := str(conversation.get("speaker", "")) + var summary := str(conversation.get("summary", "")) + var group := str(conversation.get("group", "topic")).capitalize() + var parts := [group, label] + if not speaker.is_empty(): + parts.append(speaker) + if not summary.is_empty(): + parts.append(summary) + return _join_strings(parts, " - ") + + +func _on_camp_conversation_pressed(conversation_id: String) -> void: + var conversation := _camp_conversation_by_id(conversation_id) + if conversation.is_empty(): + _play_ui_cancel() + return + _play_ui_confirm() + _hide_talk_menu() + _show_camp_dialogue(conversation.get("lines", [])) + _update_hud() + + +func _camp_conversation_by_id(conversation_id: String) -> Dictionary: + for conversation in _camp_conversation_entries(): + if str(conversation.get("id", "")) == conversation_id: + return conversation + return {} func _show_camp_dialogue(lines: Array) -> void: @@ -3029,6 +3133,7 @@ func _on_armory_pressed() -> void: else: _hide_chapter_menu() _hide_shop_menu() + _hide_talk_menu() _hide_roster_menu() _hide_formation_menu() _hide_save_menu() @@ -3052,6 +3157,7 @@ func _on_roster_pressed() -> void: else: _hide_chapter_menu() _hide_shop_menu() + _hide_talk_menu() _hide_armory_menu() _hide_formation_menu() _hide_save_menu() @@ -3075,6 +3181,7 @@ func _on_formation_pressed() -> void: else: _hide_chapter_menu() _hide_shop_menu() + _hide_talk_menu() _hide_armory_menu() _hide_roster_menu() _hide_save_menu() @@ -3099,6 +3206,7 @@ func _on_save_pressed() -> void: else: _hide_chapter_menu() _hide_shop_menu() + _hide_talk_menu() _hide_armory_menu() _hide_roster_menu() _hide_formation_menu() @@ -3215,6 +3323,7 @@ func _is_prep_menu_visible() -> bool: return ( (chapter_menu != null and chapter_menu.visible) or (shop_menu != null and shop_menu.visible) + or (talk_menu != null and talk_menu.visible) or (armory_menu != null and armory_menu.visible) or (roster_menu != null and roster_menu.visible) or (formation_menu != null and formation_menu.visible) diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 35eb1e6..0da25d1 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -29,6 +29,13 @@ func _check_battle_visual_data(failures: Array[String]) -> void: var briefing := state.get_briefing() if (briefing.get("camp_dialogue", []) as Array).is_empty(): failures.append("001 briefing should expose camp dialogue") + var conversations: Array = briefing.get("camp_conversations", []) + if conversations.size() < 3: + failures.append("001 briefing should expose at least three camp conversations") + else: + _check_camp_conversation(failures, conversations[0], "cao_cao_strategy", "officer", "cao_cao") + _check_camp_conversation(failures, conversations[1], "xiahou_dun_vanguard", "officer", "xiahou_dun") + _check_camp_conversation(failures, conversations[2], "northern_woods_cache", "topic", "") var merchant := state.get_shop_merchant() if merchant.is_empty() or (merchant.get("lines", []) as Array).is_empty(): failures.append("001 shop should expose merchant lines") @@ -63,6 +70,21 @@ func _check_scene_texture_loading(failures: Array[String]) -> void: var panacea: Dictionary = scene.state.get_item_def("panacea") if scene._load_art_texture(str(panacea.get("icon", ""))) == null: failures.append("battle scene should load panacea icon texture") + var conversations: Array = scene.state.get_briefing().get("camp_conversations", []) + if not conversations.is_empty(): + var first_lines: Array = (conversations[0] as Dictionary).get("lines", []) + if not first_lines.is_empty(): + var first_line: Dictionary = first_lines[0] + if scene._load_art_texture(str(first_line.get("portrait", ""))) == null: + failures.append("battle scene should load camp conversation speaker portrait") + var scene_conversations: Array = scene._camp_conversation_entries() + if scene_conversations.size() < 3: + failures.append("battle scene should expose camp conversation entries") + var strategy := scene._camp_conversation_by_id("cao_cao_strategy") + if strategy.is_empty(): + failures.append("battle scene should find Cao Cao camp conversation by id") + elif not scene._format_camp_conversation_button_text(strategy).contains("Cao Cao"): + failures.append("camp conversation button text should include label/speaker") scene.free() @@ -94,6 +116,26 @@ func _check_attack_motion_signal(failures: Array[String]) -> void: failures.append("attack motion signal missing expected Cao Cao attack: %s" % str(motions)) +func _check_camp_conversation(failures: Array[String], conversation: Dictionary, expected_id: String, expected_group: String, expected_officer_id: String) -> void: + if str(conversation.get("id", "")) != expected_id: + failures.append("camp conversation id mismatch: expected %s got %s" % [expected_id, str(conversation.get("id", ""))]) + if str(conversation.get("group", "")) != expected_group: + failures.append("camp conversation group mismatch for %s" % expected_id) + if str(conversation.get("officer_id", "")) != expected_officer_id: + failures.append("camp conversation officer mismatch for %s" % expected_id) + if str(conversation.get("label", "")).is_empty(): + failures.append("camp conversation label missing for %s" % expected_id) + var lines: Array = conversation.get("lines", []) + if lines.is_empty(): + failures.append("camp conversation lines missing for %s" % expected_id) + return + var first_line: Dictionary = lines[0] + if str(first_line.get("text", "")).is_empty(): + failures.append("camp conversation first text missing for %s" % expected_id) + if expected_group == "officer" and str(first_line.get("portrait", "")).is_empty(): + failures.append("camp conversation officer portrait missing for %s" % expected_id) + + func _check_image_path(failures: Array[String], path: String, context: String) -> void: if path.is_empty(): failures.append("%s has an empty image path" % context) diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index 8d950a0..80a615b 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -105,6 +105,7 @@ $validStatusStats = @("atk", "def", "int", "agi", "move") $validSkillEffectTypes = @("stat_bonus", "damage_over_time", "action_lock") $validSkillAreaShapes = @("single", "diamond") $validDialogueSides = @("left", "right") +$validCampConversationGroups = @("officer", "topic") $validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$' $minimumPortraitDimension = 512 $portraitImageInspectorReady = $false @@ -1698,6 +1699,7 @@ function Check-Briefing($Briefing, [string]$ScenarioId) { foreach ($line in @((Get-Prop $Briefing "camp_dialogue" @()))) { Check-Dialogue-Line $line "Scenario $ScenarioId briefing camp_dialogue" } + Check-Camp-Conversations (Get-Prop $Briefing "camp_conversations" $null) $ScenarioId $conditionalLines = Get-Prop $Briefing "conditional_lines" @() foreach ($block in @($conditionalLines)) { @@ -1721,6 +1723,55 @@ function Check-Briefing($Briefing, [string]$ScenarioId) { } } +function Check-Camp-Conversations($Conversations, [string]$ScenarioId) { + if ($null -eq $Conversations) { + return + } + if (-not ($Conversations -is [System.Array])) { + Fail "Scenario $ScenarioId briefing camp_conversations must be an array." + } + $seenConversationIds = New-Object System.Collections.Generic.HashSet[string] + foreach ($conversation in @($Conversations)) { + if ($conversation -is [string] -or $conversation -is [System.Array]) { + Fail "Scenario $ScenarioId briefing camp_conversations has malformed entry." + } + $conversationId = [string](Get-Prop $conversation "id" "") + if ([string]::IsNullOrWhiteSpace($conversationId)) { + Fail "Scenario $ScenarioId briefing camp_conversations has an empty id." + } + if (-not ($conversationId -match '^[a-z0-9_]+$')) { + Fail "Scenario $ScenarioId briefing camp_conversations has unstable id: $conversationId" + } + if (-not $seenConversationIds.Add($conversationId)) { + Fail "Scenario $ScenarioId briefing camp_conversations has duplicate id: $conversationId" + } + $label = [string](Get-Prop $conversation "label" "") + if ([string]::IsNullOrWhiteSpace($label)) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId has an empty label." + } + $group = ([string](Get-Prop $conversation "group" "topic")).Trim().ToLowerInvariant() + if (-not $validCampConversationGroups.Contains($group)) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId has unknown group: $group" + } + $officerId = [string](Get-Prop $conversation "officer_id" "") + if ($group -eq "officer") { + if ([string]::IsNullOrWhiteSpace($officerId)) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId needs officer_id." + } + if (-not $officerIds.Contains($officerId)) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId references unknown officer: $officerId" + } + } + $conversationLines = Get-Prop $conversation "lines" $null + if ($null -eq $conversationLines -or -not ($conversationLines -is [System.Array]) -or @($conversationLines).Count -le 0) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId must have non-empty lines." + } + foreach ($conversationLine in @($conversationLines)) { + Check-Dialogue-Line $conversationLine "Scenario $ScenarioId briefing camp_conversations $conversationId" + } + } +} + function Check-Dialogue-Line($Line, [string]$Context) { if ($Line -is [string]) { if ([string]::IsNullOrWhiteSpace([string]$Line)) {