diff --git a/README.md b/README.md index 5f793a6..677ed01 100644 --- a/README.md +++ b/README.md @@ -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, objective updates, inventory pickups, and reinforcements. +- Scenario events for battle start, battle-begin dialogue, turn start, movement-triggered ambushes, objective updates with HUD notices, inventory pickups, and reinforcements. - 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 855bf4d..aa802b2 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 and movement-triggered scenario events run as phases change or units enter marked cells, and may update objectives, grant inventory pickups, or deploy reinforcements. +11. Turn-start and movement-triggered scenario events run as phases change or units enter marked cells, and may update objectives with HUD notices, grant inventory pickups, or deploy reinforcements. 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. @@ -59,6 +59,6 @@ Joined officers gate whether `requires_joined` player deployments are loaded at Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. 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. 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. 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. 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`, 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, and item pickup effects use distinct floating feedback colors, `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, 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 c839cb0..d650cce 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -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`, and movement-triggered `unit_reaches_tile` triggers. Each event needs a stable lowercase `id` that is unique within the scenario; `after_event` conditions 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; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. Actions currently include `log`, `dialogue`, `set_objective`, `grant_item`, `grant_items`, `spawn_deployment`, and `spawn_deployments`. Event `dialogue` actions use the same dialogue line format, portrait default rules, and optional `side` placement as `post_battle_dialogue`. 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`, and movement-triggered `unit_reaches_tile` triggers. Each event needs a stable lowercase `id` that is unique within the scenario; `after_event` conditions 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; use `team`, `unit_ids`, or `officer_ids` to limit who can trigger the event. Actions currently include `log`, `dialogue`, `set_objective`, `grant_item`, `grant_items`, `spawn_deployment`, and `spawn_deployments`. `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. 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 { diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index 4277339..82fb306 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -49,6 +49,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Named equipment rewards such as Yitian Sword and Dragon Spear validate, appear with compact rarity tags in reward/inventory/equipment text, and remain ordinary stackable inventory stock until unique item instances are implemented. - Hovering an occupied tile shows the unit's class, movement type, AGI, movement, range, equipped weapon/armor/accessory, and a HUD portrait thumbnail when no unit is selected, with empty gear slots shown as `-`. - The battle HUD and briefing objective panels split objective, progress, and risk details, including pending unlocks, destination reach status, defeated enemy counts, named protected-unit or commander safety, and turns remaining. +- `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. - 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 910c0c7..abf6243 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -42,11 +42,11 @@ ## 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, and movement-triggered map tiles. +- Scenario events exist for battle start, battle begin, turn start, movement-triggered map tiles, and objective-change notices. - Mid-battle map/action events have a first action system with movement ambushes. - Opening battle dialogue exists through event actions. - Post-battle dialogue exists before the victory result panel. -- Reinforcements, objective changes, and inventory pickup events exist in a basic form. +- Reinforcements, objective changes with HUD/log feedback, and inventory 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. ## Milestone 4: Campaign Progression diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index e3c717f..453a58e 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -8,6 +8,7 @@ signal log_added(message: String) signal dialogue_requested(lines: Array) signal combat_feedback_requested(unit_id: String, text: String, kind: String) signal unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) +signal objective_updated(victory: String, defeat: String) const TEAM_PLAYER := "player" const TEAM_ENEMY := "enemy" @@ -3682,11 +3683,25 @@ func _grant_event_item(action: Dictionary, trigger_unit: Dictionary = {}) -> boo func _apply_objective_event(action: Dictionary) -> void: + var updated := false + var updated_victory := "" + var updated_defeat := "" if action.has("victory"): objectives["victory"] = str(action["victory"]) + updated_victory = str(objectives.get("victory", "")) + updated = true if action.has("defeat"): objectives["defeat"] = str(action["defeat"]) - _emit_log("Objective updated.") + updated_defeat = str(objectives.get("defeat", "")) + updated = true + if not updated: + return + if action.has("victory") and not updated_victory.is_empty(): + _emit_log("Objective updated: %s" % updated_victory) + if action.has("defeat") and not updated_defeat.is_empty(): + _emit_log("Defeat condition updated: %s" % updated_defeat) + objective_updated.emit(updated_victory, updated_defeat) + _notify_changed() func _dialogue_lines_from_action(action: Dictionary) -> Array: diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 9b35e57..1a8ea5d 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -36,6 +36,7 @@ const SFX_VOLUME_DB := -4.0 const UI_SFX_VOLUME_DB := -8.0 const FLOATING_TEXT_LIFETIME := 1.0 const FLOATING_TEXT_RISE := 42.0 +const OBJECTIVE_NOTICE_DURATION := 2.6 const UNIT_MOVE_ANIMATION_DURATION := 0.18 const TARGET_PREVIEW_BADGE_SIZE := Vector2(92, 22) const LOW_HP_WARNING_RATIO := 0.35 @@ -74,6 +75,8 @@ var objective_label: Label var campaign_status_label: Label var mission_title_label: Label var mission_detail_label: Label +var objective_notice_panel: PanelContainer +var objective_notice_label: Label var hud_unit_portrait_panel: PanelContainer var hud_unit_portrait_texture: TextureRect var hud_unit_portrait_label: Label @@ -157,6 +160,7 @@ var current_bgm_key := "" var audio_stream_cache := {} var last_announced_battle_status := "" var floating_texts: Array[Dictionary] = [] +var objective_notice_timer := 0.0 var unit_motion_by_unit: Dictionary = {} @@ -168,6 +172,7 @@ func _ready() -> void: state.dialogue_requested.connect(_on_dialogue_requested) state.combat_feedback_requested.connect(_on_combat_feedback_requested) state.unit_motion_requested.connect(_on_unit_motion_requested) + state.objective_updated.connect(_on_objective_updated) campaign_state.load_campaign(CAMPAIGN_PATH) campaign_state.load_or_start(campaign_state.get_start_scenario_id()) if campaign_state.has_pending_post_battle_choice(): @@ -252,6 +257,20 @@ func _create_hud() -> void: new_campaign_button.pressed.connect(_on_new_campaign_pressed) top_row.add_child(new_campaign_button) + objective_notice_panel = PanelContainer.new() + objective_notice_panel.visible = false + objective_notice_panel.position = Vector2(360, 84) + objective_notice_panel.size = Vector2(560, 64) + objective_notice_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(objective_notice_panel) + + objective_notice_label = Label.new() + objective_notice_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + objective_notice_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + objective_notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + objective_notice_label.custom_minimum_size = Vector2(520, 48) + objective_notice_panel.add_child(objective_notice_label) + var side_panel := PanelContainer.new() side_panel.position = Vector2(760, 104) side_panel.size = Vector2(480, 610) @@ -814,6 +833,8 @@ func _play_ui_cancel() -> void: func _play_log_sfx(message: String) -> void: if message.contains(" but misses."): _play_sfx("guard_block") + elif message.begins_with("Objective updated:") or message.begins_with("Defeat condition updated:"): + _play_ui_confirm() elif message.contains(" attacks ") or message.contains(" counterattacks "): if message.contains(" for "): _play_sfx("hit_heavy") @@ -974,6 +995,11 @@ func _handle_key(event: InputEventKey) -> void: func _process(delta: float) -> void: var needs_redraw := false + if objective_notice_timer > 0.0: + objective_notice_timer = maxf(0.0, objective_notice_timer - delta) + if objective_notice_timer <= 0.0 and objective_notice_panel != null: + objective_notice_panel.visible = false + if not floating_texts.is_empty(): var active_texts: Array[Dictionary] = [] for popup in floating_texts: @@ -2015,6 +2041,21 @@ func _on_log_added(message: String) -> void: log_box.scroll_to_line(max(0, log_box.get_line_count() - 1)) +func _on_objective_updated(victory: String, defeat: String) -> void: + var notice_lines := ["Objective Updated"] + if not victory.is_empty(): + notice_lines.append(victory) + if not defeat.is_empty(): + notice_lines.append(defeat) + if objective_notice_label != null: + objective_notice_label.text = "\n".join(notice_lines) + if objective_notice_panel != null: + objective_notice_panel.visible = true + objective_notice_timer = OBJECTIVE_NOTICE_DURATION + _update_hud() + queue_redraw() + + func _on_dialogue_requested(lines: Array) -> void: var normalized_lines := _normalized_dialogue_lines(lines) if normalized_lines.is_empty(): diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd index b4e16bc..6370660 100644 --- a/tools/smoke_objective_progress.gd +++ b/tools/smoke_objective_progress.gd @@ -1,6 +1,7 @@ extends SceneTree const BattleStateScript := preload("res://scripts/core/battle_state.gd") +const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") func _init() -> void: @@ -48,6 +49,8 @@ func _init() -> void: _progress_text("res://data/scenarios/045_fancheng_relief.json", false), "Reach (10, 5): not reached" ) + _check_objective_update(failures) + _check_objective_notice(failures) if failures.is_empty(): print("objective progress smoke ok") @@ -73,6 +76,74 @@ func _defeat_text(path: String) -> String: return state.get_defeat_progress_text() +func _check_objective_update(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("objective update smoke could not load scenario") + return + + var signal_values := [] + var logs := [] + state.objective_updated.connect(func(victory: String, defeat: String) -> void: + signal_values.append({"victory": victory, "defeat": defeat}) + ) + state.log_added.connect(func(message: String) -> void: + logs.append(message) + ) + state._apply_objective_event({ + "victory": "Defeat the new vanguard.", + "defeat": "Cao Cao is defeated." + }) + if signal_values.size() != 1: + failures.append("objective update signal expected 1 emission, got %d" % signal_values.size()) + else: + var signal_value: Dictionary = signal_values[0] + if str(signal_value.get("victory", "")) != "Defeat the new vanguard.": + failures.append("objective update signal victory mismatch: %s" % str(signal_value.get("victory", ""))) + if str(signal_value.get("defeat", "")) != "Cao Cao is defeated.": + failures.append("objective update signal defeat mismatch: %s" % str(signal_value.get("defeat", ""))) + _check_log_contains(failures, logs, "Objective updated: Defeat the new vanguard.") + _check_log_contains(failures, logs, "Defeat condition updated: Cao Cao is defeated.") + + signal_values.clear() + logs.clear() + state._apply_objective_event({"defeat": "Supply train is lost."}) + if signal_values.size() != 1: + failures.append("defeat-only objective signal expected 1 emission, got %d" % signal_values.size()) + else: + var defeat_signal_value: Dictionary = signal_values[0] + if not str(defeat_signal_value.get("victory", "")).is_empty(): + failures.append("defeat-only objective signal should not include victory: %s" % str(defeat_signal_value.get("victory", ""))) + if str(defeat_signal_value.get("defeat", "")) != "Supply train is lost.": + failures.append("defeat-only objective signal mismatch: %s" % str(defeat_signal_value.get("defeat", ""))) + _check_log_contains(failures, logs, "Defeat condition updated: Supply train is lost.") + + +func _check_log_contains(failures: Array[String], logs: Array, expected: String) -> void: + for log_entry in logs: + if str(log_entry) == expected: + return + failures.append("expected log `%s` in `%s`" % [expected, logs]) + + +func _check_objective_notice(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._create_hud() + scene._on_objective_updated("Defeat the new vanguard.", "") + if scene.objective_notice_panel == null or not scene.objective_notice_panel.visible: + failures.append("objective notice panel should be visible after objective update") + elif not scene.objective_notice_label.text.contains("Defeat the new vanguard."): + failures.append("objective notice label missing updated objective: %s" % scene.objective_notice_label.text) + scene._on_objective_updated("", "Supply train is lost.") + if scene.objective_notice_panel == null or not scene.objective_notice_panel.visible: + failures.append("objective notice panel should be visible after defeat update") + elif not scene.objective_notice_label.text.contains("Supply train is lost."): + failures.append("objective notice label missing updated defeat condition: %s" % scene.objective_notice_label.text) + if scene.objective_notice_timer <= 0.0: + failures.append("objective notice timer should be active") + scene.free() + + func _check_contains(failures: Array[String], label: String, text: String, expected: String) -> void: if text.contains(expected): return