diff --git a/README.md b/README.md index 1faaa5e..55b5ca8 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - 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. -- Destination-style victory conditions with single-tile or multi-cell objective map markers. +- Destination-style victory conditions with single-tile, multi-cell, and event-gated objective map markers. - Protected-unit defeat conditions for escort battles. - Turn-limit defeat conditions with turn-limit HUD display. - Live objective panels in the active HUD and briefing split objective, progress, and risk details, including gated unlocks, destination markers, defeated enemy counts, named protected-unit safety, and turns remaining. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2a43dd9..89d08f6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 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 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 or cure matching poison/seal/snare/disarm statuses, 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 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; 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 or cure matching poison/seal/snare/disarm statuses, 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 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/gold 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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index a0b99ca..3d21beb 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -436,7 +436,7 @@ 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. -`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. +`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`. @@ -459,7 +459,7 @@ Conditions may include `after_event` to stay inactive until a one-shot event has `turn_reached` is the precise condition form. `turn_limit` is a shorthand for "win by the end of this turn" and is evaluated as a player-phase defeat after the limit is exceeded. -`unit_reaches_tile` checks living deployed units against either one zero-based `pos` coordinate or a `cells` array such as `[[10, 4], [11, 4]]`. 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, including every entry in `cells`. +`unit_reaches_tile` checks living deployed units against either one zero-based `pos` coordinate or a `cells` array such as `[[10, 4], [11, 4]]`. 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, including every entry in `cells`. If a pending victory condition waits on an `after_event` whose trigger is `unit_reaches_tile`, that event's `pos` or `cells` are also drawn as the current objective marker until the event fires. `post_battle_dialogue` plays once after victory and before the victory result panel opens. Lines may be plain strings or objects with `speaker`, non-empty `text`, optional `portrait` resource paths such as `res://art/portraits/cao_cao.png`, and optional `side` values of `left` or `right` for portrait placement. Portrait paths must stay inside the project, point to existing readable image files, and keep at least a 512x512 source resolution. If `side` is omitted, it defaults to `left`. If `portrait` is omitted, `BattleState` uses a matching officer definition's default portrait path when the line's `speaker` matches the officer `name`. If no usable portrait is loaded, the battle scene falls back to a speaker-initial panel. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 25ab295..aee4673 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,7 +19,7 @@ - Enemy units take AI turns with movement, damage-aware physical attacks, and basic tactic use. - Victory and defeat states are visible. - Scenario-defined victory and defeat conditions exist. -- Destination victory conditions and single-tile or multi-cell objective map markers exist. +- Destination victory conditions and single-tile, multi-cell, or event-gated objective map markers exist. - Turn-limit conditions and HUD turn-limit display exist. - Active HUD and briefing objective panels split objective, progress, and risk details, including gated unlocks, destinations, defeated enemies, protected-unit safety, and turns remaining. - Victory result summary splits rewards, roster changes, saved growth, campaign status, and pending choices into readable sections. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 4949ef6..3a3dbfe 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -3621,6 +3621,7 @@ func _collect_objective_cells(condition_group, result: Array[Vector2i]) -> void: if typeof(condition_group) != TYPE_DICTIONARY: return if not _condition_gate_open(condition_group): + _collect_after_event_objective_cells(str(condition_group.get("after_event", "")), result) return var condition_type := str(condition_group.get("type", "")) @@ -3635,6 +3636,22 @@ func _collect_objective_cells(condition_group, result: Array[Vector2i]) -> void: result.append(cell) +func _collect_after_event_objective_cells(event_id: String, result: Array[Vector2i]) -> void: + if event_id.is_empty() or fired_event_ids.has(event_id): + return + var event := _event_by_id(event_id) + if event.is_empty(): + return + var when: Dictionary = event.get("when", {}) + if str(when.get("type", "")) != "unit_reaches_tile": + return + if not _event_gate_open(when) or not _campaign_flags_match(when.get("campaign_flags", {})): + return + for cell in _condition_cells(when): + if not result.has(cell): + result.append(cell) + + func _find_turn_limit(condition_group) -> int: if typeof(condition_group) == TYPE_ARRAY: var best_limit := 0 diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd index ea98422..b3d831f 100644 --- a/tools/smoke_objective_progress.gd +++ b/tools/smoke_objective_progress.gd @@ -55,6 +55,9 @@ func _init() -> void: _progress_text("res://data/scenarios/045_fancheng_relief.json", false), "Reach (10, 5): not reached" ) + _check_event_gated_objective_markers(failures) + _check_event_gated_multi_cell_objective_markers(failures) + _check_turn_gated_objective_does_not_create_marker(failures) _check_objective_update(failures) _check_objective_notice(failures) @@ -125,6 +128,76 @@ func _check_objective_update(failures: Array[String]) -> void: _check_log_contains(failures, logs, "Defeat condition updated: Supply train is lost.") +func _check_event_gated_objective_markers(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/013_wuchao_raid.json"): + failures.append("event-gated objective marker smoke could not load Wuchao raid") + return + + var depot_cell := Vector2i(14, 4) + var initial_cells := state.get_objective_cells() + if not initial_cells.has(depot_cell): + failures.append("Wuchao depot marker missing before event opens victory condition: %s" % str(initial_cells)) + + var cao_cao: Dictionary = state.get_unit("cao_cao_ch13") + if cao_cao.is_empty(): + failures.append("event-gated objective marker smoke missing Cao Cao") + return + cao_cao["pos"] = depot_cell + state._run_events("unit_reaches_tile", "player", 1, cao_cao) + var opened_cells := state.get_objective_cells() + if opened_cells.has(depot_cell): + failures.append("Wuchao depot marker should clear after event opens defeat-all objective: %s" % str(opened_cells)) + + +func _check_event_gated_multi_cell_objective_markers(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("multi-cell event-gated objective marker smoke could not load scenario") + return + + state.battle_conditions["victory"] = { + "type": "all_units_defeated", + "team": "enemy", + "after_event": "multi_cell_gate" + } + state.battle_events.clear() + state.battle_events.append({ + "id": "multi_cell_gate", + "once": true, + "when": {"type": "unit_reaches_tile", "team": "player", "cells": [[3, 2], [3, 3]]}, + "actions": [{"type": "log", "text": "Gate opened."}] + }) + + var marker_cells := state.get_objective_cells() + if not marker_cells.has(Vector2i(3, 2)) or not marker_cells.has(Vector2i(3, 3)): + failures.append("multi-cell event-gated marker should include every event cell: %s" % str(marker_cells)) + + +func _check_turn_gated_objective_does_not_create_marker(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("turn-gated objective marker smoke could not load scenario") + return + + state.battle_conditions["victory"] = { + "type": "all_units_defeated", + "team": "enemy", + "after_event": "turn_gate" + } + state.battle_events.clear() + state.battle_events.append({ + "id": "turn_gate", + "once": true, + "when": {"type": "turn_start", "team": "player", "turn": 2}, + "actions": [{"type": "log", "text": "Turn gate opened."}] + }) + + var marker_cells := state.get_objective_cells() + if not marker_cells.is_empty(): + failures.append("turn-gated objective should not create map markers: %s" % str(marker_cells)) + + func _check_log_contains(failures: Array[String], logs: Array, expected: String) -> void: for log_entry in logs: if str(log_entry) == expected: