diff --git a/README.md b/README.md index 734e0b2..e78ff55 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Grid movement. - Unit selection. - Move, attack, wait, end turn. -- Enemy AI with movement, damage-aware physical attacks, multiple MP tactic choices, and scenario target priorities. +- Enemy AI with movement, damage-aware physical attacks, multiple MP tactic choices, and scenario target priorities that events can retune mid-battle. - Hover tile and unit info with class, movement type, AGI, movement, range, and equipped gear. - Enemy threat range overlay with tile-level threat source, physical damage hints, and hostile tactic hints. - Damage forecast for selected-unit attacks. @@ -98,7 +98,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Scenario post-battle dialogue can play before the victory result panel. - Basic battle EXP, level-ups, and core first-tier class promotion routes. - Victory results split rewards, roster changes, saved growth, campaign status, and pending choices into readable sections. -- Scenario events for battle start, battle-begin dialogue, turn start, movement-triggered ambushes, unit-defeat reactions, `after_event` prerequisites, objective updates with HUD notices, inventory pickups, reinforcements, and scripted unit withdrawals. +- Scenario events for battle start, battle-begin dialogue, turn start, movement-triggered ambushes, unit-defeat reactions, `after_event` prerequisites, objective updates with HUD notices, inventory pickups, reinforcements, scripted unit withdrawals, and AI target-priority shifts. - Scenario briefing before battle with a campaign battle header and objective summary. - Victory rewards and consumed inventory saved into a campaign state. - Scenario-defined victory and defeat conditions. diff --git a/data/scenarios/008_wan_castle_escape.json b/data/scenarios/008_wan_castle_escape.json index b2e19ef..484f3e5 100644 --- a/data/scenarios/008_wan_castle_escape.json +++ b/data/scenarios/008_wan_castle_escape.json @@ -230,6 +230,12 @@ "victory": "Break through the western road and move Cao Cao to the escape marker.", "defeat": "Cao Cao or Cao Ang is defeated, or the battle reaches Turn 12." }, + { + "type": "set_ai_target_priority", + "unit_id": "cao_ang_ch8", + "priority": 10, + "text": "Cao Ang is exposed as the ambush closes." + }, { "type": "spawn_deployments", "deployments": [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f08722..5e61442 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,7 +43,7 @@ 8. Player ends turn. 9. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock tactics through the same combat and skill resolvers. 10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. -11. Turn-start, movement-triggered, and unit-defeat scenario events run as phases change, units enter marked cells, or units fall, can wait for prerequisite events, and may update objectives with HUD notices, grant inventory pickups, deploy reinforcements, or withdraw units from the battlefield. +11. Turn-start, movement-triggered, and unit-defeat scenario events run as phases change, units enter marked cells, or units fall, can wait for prerequisite events, and may update objectives with HUD notices, grant inventory pickups, deploy reinforcements, withdraw units from the battlefield, or change AI target priorities. 12. On first-time victory, `CampaignState` snapshots player progression and the battle inventory, applies rewards once, advances `current_scenario_id`, and writes `user://campaign_save.json`. 13. If the scenario defines post-battle dialogue, it plays once before the victory result panel opens. 14. The victory result panel splits rewards, roster changes, saved player growth, campaign status, and pending choices into sections. @@ -57,8 +57,8 @@ 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 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. 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. 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. 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 pickups, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP or cure matching poison/seal/snare/disarm statuses, and in-battle item use, event pickup grants, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up 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. 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, 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 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. 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. 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 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 or cure matching poison/seal/snare/disarm statuses, and in-battle item use, event pickup grants, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up 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. 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, 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 for the selected unit or hovered unit, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, 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, and item pickup effects use distinct feedback, `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` 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 for the selected unit or hovered unit, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, 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 pickup effects use distinct feedback, `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 1180988..5ea5be0 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -348,7 +348,7 @@ The pre-battle Armory operates on the currently loaded player deployments and th Scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` control the first pre-battle Roster selection pass. Required officers and required scenario units always deploy, optional player deployments can move between sortie and reserve, and reserve units do not participate in map occupancy, targeting, or living-unit victory checks. If `roster` is omitted, all player deployments sortie as before. -Scenario-only protected units can use `team: "player"`, `controllable: false`, `persist_progression: false`, and an optional `ai_target_priority`. They remain valid targets for enemies, healing, and defeat conditions, but the player cannot select, move, attack, change equipment, or place them in Formation, and they are omitted from campaign roster progression snapshots. `ai_target_priority` is a non-negative scenario hint that makes enemy movement and damage-skill AI treat that unit as a more attractive target. +Scenario-only protected units can use `team: "player"`, `controllable: false`, `persist_progression: false`, and an optional `ai_target_priority`. They remain valid targets for enemies, healing, and defeat conditions, but the player cannot select, move, attack, change equipment, or place them in Formation, and they are omitted from campaign roster progression snapshots. `ai_target_priority` is a non-negative scenario hint that makes enemy movement and damage-skill AI treat that unit as a more attractive target. Events can later change the same value with `set_ai_target_priority`. Scenario `formation.cells` controls the highlighted starting cells available during pre-battle Formation. These choices only change the current battle's unit positions; they are not saved to campaign state. If a scenario omits `formation`, player deployment positions become the default formation cells. @@ -465,7 +465,7 @@ Conditions may include `after_event` to stay inactive until a one-shot event has `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`, movement-triggered `unit_reaches_tile`, and defeat-triggered `unit_defeated` triggers. Each event needs a stable lowercase `id` that is unique within the scenario; condition and event `after_event` gates can only reference those explicit ids. `battle_begin` runs after the briefing is closed. `unit_reaches_tile` events run when a moved unit enters the zero-based `pos` cell or any zero-based entry in `cells`; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. `unit_defeated` events run after a deployed unit is marked defeated and before final victory/defeat checks; use `team`, `unit_ids`, or `officer_ids` to limit which fallen unit can trigger the event. Event `when` blocks may include `after_event` to wait until an earlier one-shot scenario beat has fired, which is useful for dialogue or follow-up pressure that only makes sense after a destination, ambush, gate, fire event, or named-unit defeat. Actions currently include `log`, `dialogue`, `set_objective`, `grant_item`, `grant_items`, `spawn_deployment`, `spawn_deployments`, `withdraw_unit`, and `withdraw_units`. `set_objective` updates visible objective text, emits concrete log lines for changed victory/defeat text, and raises an objective-update signal so the battle scene can refresh the HUD and show a short notice. `withdraw_unit` accepts `unit_id` or `id`, while `withdraw_units` accepts a `unit_ids` array; living deployed targets leave the battlefield without being marked defeated, so they are removed from map occupancy and living-unit counts while protected-unit defeat checks still distinguish withdrawal from death. Event `dialogue` actions use the same dialogue line format, portrait default rules, and optional `side` placement as `post_battle_dialogue`. Event item grants add to the battle inventory immediately; they persist to the campaign save only when the battle is won. +Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, movement-triggered `unit_reaches_tile`, and defeat-triggered `unit_defeated` triggers. Each event needs a stable lowercase `id` that is unique within the scenario; condition and event `after_event` gates can only reference those explicit ids. `battle_begin` runs after the briefing is closed. `unit_reaches_tile` events run when a moved unit enters the zero-based `pos` cell or any zero-based entry in `cells`; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. `unit_defeated` events run after a deployed unit is marked defeated and before final victory/defeat checks; use `team`, `unit_ids`, or `officer_ids` to limit which fallen unit can trigger the event. Event `when` blocks may include `after_event` to wait until an earlier one-shot scenario beat has fired, which is useful for dialogue or follow-up pressure that only makes sense after a destination, ambush, gate, fire event, or named-unit defeat. Actions currently include `log`, `dialogue`, `set_objective`, `grant_item`, `grant_items`, `spawn_deployment`, `spawn_deployments`, `withdraw_unit`, `withdraw_units`, and `set_ai_target_priority`. `set_objective` updates visible objective text, emits concrete log lines for changed victory/defeat text, and raises an objective-update signal so the battle scene can refresh the HUD and show a short notice. `set_ai_target_priority` accepts `unit_id` or `unit_ids`, a `priority` from 0 to 20, optional `text`, and optional `silent`; it changes enemy AI target scoring immediately and, unless silent, logs the change plus shows target-focus floating text. `withdraw_unit` accepts `unit_id` or `id`, while `withdraw_units` accepts a `unit_ids` array; living deployed targets leave the battlefield without being marked defeated, so they are removed from map occupancy and living-unit counts while protected-unit defeat checks still distinguish withdrawal from death. Event `dialogue` actions use the same dialogue line format, portrait default rules, and optional `side` placement as `post_battle_dialogue`. Event item grants add to the battle inventory immediately; they persist to the campaign save only when the battle is won. ```json { @@ -510,6 +510,7 @@ Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, movement { "type": "set_objective", "victory": "Defeat the vanguard and reinforcements." }, { "type": "grant_item", "item_id": "bean" }, { "type": "withdraw_units", "unit_ids": ["enemy_scout_1", "enemy_scout_2"] }, + { "type": "set_ai_target_priority", "unit_id": "protected_envoy", "priority": 10, "text": "The envoy is exposed." }, { "type": "spawn_deployment", "deployment": { @@ -586,4 +587,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 and cure statuses, officer and deployment portraits, 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 grants, event withdrawal unit ids, condition unit references, destination `pos`/`cells` coordinates, campaign flag references, joined-officer gates, briefing line blocks, deployment ids, deployment bounds, impassable spawn cells, shop stock, 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 and cure statuses, officer and deployment portraits, 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 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, deployment ids, deployment bounds, impassable spawn cells, shop stock, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index 0518bf0..e161383 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -53,6 +53,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - `unit_defeated` scenario events fire named-unit reactions before final battle status checks, including Yan Liang and Wen Chou defeat dialogue. - `set_objective` scenario events show concrete objective-update logs, play a UI confirmation sound, refresh the HUD immediately, and briefly show the objective notice panel. - `withdraw_unit` and `withdraw_units` scenario events remove living targets from the battlefield without marking them defeated. +- `set_ai_target_priority` scenario events retune enemy focus during battle and show target-focus feedback unless marked silent. - Movement-triggered supply cache events grant battle inventory items with pickup feedback, and the items persist only after victory. - The Threat toggle or `Y` key shows enemy physical and hostile tactic reach, hovered threatened tiles name the threatening units, and occupied player tiles show physical damage/hit estimates plus hostile tactic damage or status hints. - Area tactics such as Blaze, Great Mend, and Poison Mist highlight affected cells, apply only to valid targets in the area, and spend MP once per cast. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f8c1fa6..5790806 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -42,7 +42,7 @@ ## Milestone 3: Scenario Layer - Pre-battle briefing exists with a campaign chapter header, battle header, objective summary, and condition tracker. Full dialogue scenes are still planned. -- Scenario events exist for battle start, battle begin, turn start, movement-triggered map tiles, unit defeat reactions, event prerequisites, and objective-change notices. +- Scenario events exist for battle start, battle begin, turn start, movement-triggered map tiles, unit defeat reactions, event prerequisites, objective-change notices, and AI target-priority shifts. - 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. @@ -135,7 +135,7 @@ - Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, battle HUD thumbnails reuse them for selected or hovered units, 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. +- Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, item pickups, AI target-focus changes, 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. - Chapter UI, objective presentation, and visual novel scenes. Pre-battle briefing now uses campaign chapter ranges for chapter-aware numbering, has a chapter progress/replay overview plus objective/progress/risk tracker, active battle HUD shows the same live objective panel, and dialogue lines can choose left or right portrait placement in an expanded portrait dialogue panel with progress and previous-line controls. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 4e00bd4..87808f6 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -3696,6 +3696,8 @@ func _execute_event_action(action: Dictionary, trigger_unit: Dictionary = {}) -> return for unit_id in unit_ids: _withdraw_event_unit(str(unit_id)) + elif action_type == "set_ai_target_priority": + _set_event_ai_target_priority(action) func _grant_event_items(item_entries, trigger_unit: Dictionary = {}) -> void: @@ -3730,6 +3732,52 @@ func _grant_event_item(action: Dictionary, trigger_unit: Dictionary = {}) -> boo return true +func _set_event_ai_target_priority(action: Dictionary) -> void: + var unit_ids := _event_action_unit_ids(action) + if unit_ids.is_empty(): + push_error("Skipping AI target priority event with no unit ids in %s." % battle_id) + return + var priority := clampi(int(action.get("priority", 0)), 0, 20) + for unit_id in unit_ids: + var unit := get_unit(unit_id) + if unit.is_empty(): + push_error("Skipping unknown AI target priority unit %s in %s." % [unit_id, battle_id]) + continue + var previous_priority := int(unit.get("ai_target_priority", 0)) + unit["ai_target_priority"] = priority + if bool(action.get("silent", false)): + continue + _emit_ai_target_priority_feedback(unit, previous_priority, priority, str(action.get("text", ""))) + _notify_changed() + + +func _event_action_unit_ids(action: Dictionary) -> Array[String]: + var result: Array[String] = [] + if action.has("unit_ids") and typeof(action["unit_ids"]) == TYPE_ARRAY: + for unit_id in action["unit_ids"]: + var unit_id_text := str(unit_id).strip_edges() + if not unit_id_text.is_empty() and not result.has(unit_id_text): + result.append(unit_id_text) + else: + var unit_id_text := str(action.get("unit_id", action.get("id", ""))).strip_edges() + if not unit_id_text.is_empty(): + result.append(unit_id_text) + return result + + +func _emit_ai_target_priority_feedback(unit: Dictionary, previous_priority: int, priority: int, text: String = "") -> void: + if text.is_empty(): + if priority > previous_priority: + text = "%s is exposed to enemy focus." % str(unit.get("name", "Unit")) + elif priority < previous_priority: + text = "%s is less exposed to enemy focus." % str(unit.get("name", "Unit")) + else: + text = "%s remains an enemy priority." % str(unit.get("name", "Unit")) + _emit_log(text) + if bool(unit.get("alive", false)) and bool(unit.get("deployed", true)): + _emit_combat_feedback(unit, "TARGET", "priority") + + func _apply_objective_event(action: Dictionary) -> void: var updated := false var updated_victory := "" diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index ef7d576..ed3a82f 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -1253,6 +1253,8 @@ func _floating_text_color(kind: String) -> Color: return Color(0.48, 0.74, 1.0) if kind == "item": return Color(0.98, 0.76, 0.28) + if kind == "priority": + return Color(1.0, 0.54, 0.22) if kind == "support": return Color(1.0, 0.88, 0.28) if kind == "debuff": diff --git a/tools/smoke_event_ai_priority.gd b/tools/smoke_event_ai_priority.gd new file mode 100644 index 0000000..5df1cae --- /dev/null +++ b/tools/smoke_event_ai_priority.gd @@ -0,0 +1,68 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") + + +func _init() -> void: + var failures: Array[String] = [] + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + push_error("Could not load AI priority smoke scenario.") + quit(1) + return + + _check_priority_action(state, failures) + _check_priority_changes_enemy_targeting(state, failures) + + if failures.is_empty(): + print("event ai priority smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_priority_action(state, failures: Array[String]) -> void: + var logs: Array[String] = [] + var feedback: Array[Dictionary] = [] + state.log_added.connect(func(message: String) -> void: + logs.append(message) + ) + state.combat_feedback_requested.connect(func(unit_id: String, text: String, kind: String) -> void: + feedback.append({"unit_id": unit_id, "text": text, "kind": kind}) + ) + + state._execute_event_action({ + "type": "set_ai_target_priority", + "unit_id": "xiahou_dun", + "priority": 9, + "text": "Xiahou Dun draws the enemy's focus." + }) + + var target: Dictionary = state.get_unit("xiahou_dun") + if int(target.get("ai_target_priority", 0)) != 9: + failures.append("AI target priority did not update on event action") + if not logs.has("Xiahou Dun draws the enemy's focus."): + failures.append("AI priority event log missing") + if not _feedback_has(feedback, "xiahou_dun", "TARGET", "priority"): + failures.append("AI priority feedback missing") + + +func _check_priority_changes_enemy_targeting(state, failures: Array[String]) -> void: + var enemy: Dictionary = state.get_unit("yellow_turban_1") + var target: Dictionary = state._find_nearest_target(enemy, "player") + if str(target.get("id", "")) != "xiahou_dun": + failures.append("AI priority did not affect nearest target scoring: %s" % str(target.get("id", ""))) + + +func _feedback_has(feedback: Array[Dictionary], expected_unit_id: String, expected_text: String, expected_kind: String) -> bool: + for entry in feedback: + if ( + str(entry.get("unit_id", "")) == expected_unit_id + and str(entry.get("text", "")) == expected_text + and str(entry.get("kind", "")) == expected_kind + ): + return true + return false diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index 541f2d8..dc0edb7 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -93,7 +93,7 @@ $validConditionTypes = @( "any" ) $validTriggers = @("battle_start", "battle_begin", "turn_start", "unit_reaches_tile", "unit_defeated") -$validActions = @("log", "dialogue", "set_objective", "grant_item", "grant_items", "spawn_deployment", "spawn_deployments", "withdraw_unit", "withdraw_units") +$validActions = @("log", "dialogue", "set_objective", "grant_item", "grant_items", "spawn_deployment", "spawn_deployments", "withdraw_unit", "withdraw_units", "set_ai_target_priority") $validEffects = @("heal_hp", "heal_mp", "cure_status") $validItemKinds = @("weapon", "armor", "accessory", "consumable") $validItemRarities = @("common", "named") @@ -647,6 +647,59 @@ function Check-Event-Withdraw-Units($UnitIds, $KnownUnitIds, [string]$Context) { } } +function Check-Event-Unit-Ids($Action, $KnownUnitIds, [string]$Context) { + $unitIds = @() + if (Has-Prop $Action "unit_ids") { + if (-not ($Action.unit_ids -is [System.Array])) { + Fail "$Context unit_ids must be an array." + } + $unitIds = @($Action.unit_ids) + } else { + $unitId = [string](Get-Prop $Action "unit_id" (Get-Prop $Action "id" "")) + if (-not [string]::IsNullOrWhiteSpace($unitId)) { + $unitIds = @($unitId) + } + } + + if ($unitIds.Count -le 0) { + Fail "$Context needs unit_id or unit_ids." + } + $seenUnitIds = New-Object System.Collections.Generic.HashSet[string] + foreach ($unitIdValue in $unitIds) { + $unitId = [string]$unitIdValue + if ([string]::IsNullOrWhiteSpace($unitId)) { + Fail "$Context has empty unit id." + } + if (-not $KnownUnitIds.Contains($unitId)) { + Fail "$Context references unknown unit: $unitId" + } + if (-not $seenUnitIds.Add($unitId)) { + Fail "$Context has duplicate unit: $unitId" + } + } +} + +function Check-Event-Ai-Target-Priority($Action, $KnownUnitIds, [string]$Context) { + Check-Event-Unit-Ids $Action $KnownUnitIds $Context + if (-not (Has-Prop $Action "priority")) { + Fail "$Context must set priority." + } + $priorityValue = Get-Prop $Action "priority" 0 + if (-not ($priorityValue -is [int] -or $priorityValue -is [long])) { + Fail "$Context priority must be an integer." + } + $priority = [int]$priorityValue + if ($priority -lt 0 -or $priority -gt 20) { + Fail "$Context priority must be between 0 and 20." + } + if ((Has-Prop $Action "text") -and [string]::IsNullOrWhiteSpace([string](Get-Prop $Action "text" ""))) { + Fail "$Context text must not be empty." + } + if ((Has-Prop $Action "silent") -and -not ((Get-Prop $Action "silent" $false) -is [bool])) { + Fail "$Context silent must be boolean." + } +} + function Check-Class-Equipment-Slots() { $weaponTypes = @() $armorTypes = @() @@ -1880,6 +1933,9 @@ foreach ($scenario in $campaign.scenarios) { if ($actionType -eq "withdraw_units") { Check-Event-Withdraw-Units (Get-Prop $action "unit_ids" $null) $knownUnitIds "Scenario $scenarioId event $eventId withdraw_units" } + if ($actionType -eq "set_ai_target_priority") { + Check-Event-Ai-Target-Priority $action $knownUnitIds "Scenario $scenarioId event $eventId set_ai_target_priority" + } } }