From f15caf49e795c0a88c64ee5f781430abafd6726f Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 13:21:39 +0900 Subject: [PATCH] Add objective progress tracker --- README.md | 1 + docs/ARCHITECTURE.md | 2 +- docs/DATA_MODEL.md | 2 + docs/GODOT_4_6_MIGRATION.md | 1 + docs/ROADMAP.md | 5 +- scripts/core/battle_state.gd | 278 ++++++++++++++++++++++++++ scripts/scenes/battle_scene.gd | 22 +- tools/smoke_objective_progress.gd | 72 +++++++ tools/smoke_objective_progress.gd.uid | 1 + 9 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 tools/smoke_objective_progress.gd create mode 100644 tools/smoke_objective_progress.gd.uid diff --git a/README.md b/README.md index cfdd533..eb40f89 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Destination-style victory conditions with objective map markers. - Protected-unit defeat conditions for escort battles. - Turn-limit defeat conditions with turn-limit HUD display. +- Live objective progress in the active HUD and briefing tracker, including gated objective unlocks, destination markers, defeated enemy counts, named protected-unit safety, and turns remaining. - Next-battle flow after victory. - Campaign completion screen. - New campaign save reset. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 49d907b..7344004 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 and objective text, active battles use battle BGM, 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, and condition-progress tracker, active battles use battle BGM and show live objective progress from `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 `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 b11e3d8..05f89a0 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -436,6 +436,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. +`BattleState` also exposes condition progress text for presentation. The active HUD and briefing tracker can show 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. + 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`. When a saved campaign is loaded, `CampaignState` reconciles completed-scenario officer membership in campaign order. It reapplies only `join_officers` and `leave_officers` from completed scenario rewards plus the saved choice id in `applied_post_battle_choices`. Old saves without that choice ledger are backfilled only when exactly one completed scenario choice has `set_flags` that match saved campaign flags. If zero or multiple choices match, choice membership is skipped for that scenario. Gold, items, roster progression, flags, and completed ids are not replayed by this reconciliation pass. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index 7bbec0a..17d7186 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -48,6 +48,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear; weapon effectiveness appears in forecasts and equipment/shop text when applicable, and equipment options show stat/range change hints. - 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 tracker show live objective progress, including pending unlocks, destination reach status, defeated enemy counts, named protected-unit or commander safety, and turns remaining. - 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 544e25e..6b33b65 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,6 +21,7 @@ - Scenario-defined victory and defeat conditions exist. - Destination victory conditions and objective map markers exist. - Turn-limit conditions and HUD turn-limit display exist. +- Active HUD and briefing objective trackers show live condition progress, including gated unlocks, destinations, defeated enemies, protected-unit safety, and turns remaining. - Victory reward summary is visible. - Next-battle flow exists across the current sixty-seven-scenario campaign. - Campaign completion and new-campaign reset exist. @@ -40,7 +41,7 @@ ## Milestone 3: Scenario Layer -- Pre-battle briefing exists with a campaign chapter header, battle header, and objective summary. Full dialogue scenes are still planned. +- 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. - Mid-battle map/action events have a first action system with movement ambushes. - Opening battle dialogue exists through event actions. @@ -136,7 +137,7 @@ - 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. - Music and sound. Basic placeholder BGM and SFX now play for menus, battle flow, unit actions, tactics, items, and result states. -- Chapter UI and visual novel scenes. Pre-battle briefing now uses campaign chapter ranges for chapter-aware numbering, has a chapter progress/replay overview, and dialogue lines can choose left or right portrait placement in an expanded portrait dialogue panel with progress and previous-line controls. +- 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 tracker, active battle HUD shows live objective progress, and dialogue lines can choose left or right portrait placement in an expanded portrait dialogue panel with progress and previous-line controls. ## Milestone 6: Content Expansion diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 4a51b35..f6ed5a2 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -1423,6 +1423,20 @@ func get_objective_cells() -> Array[Vector2i]: return result +func get_objective_progress_lines(include_defeat_risks := false) -> Array[String]: + var result: Array[String] = [] + _append_condition_progress_lines(battle_conditions.get("victory", {}), result, "victory") + if include_defeat_risks: + _append_condition_progress_lines(battle_conditions.get("defeat", {}), result, "defeat") + else: + _append_turn_limit_progress_line(result) + return result + + +func get_objective_progress_text(include_defeat_risks := false) -> String: + return _join_strings(get_objective_progress_lines(include_defeat_risks), " | ") + + func get_first_skill_id(unit_id: String) -> String: var unit := get_unit(unit_id) if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: @@ -3120,6 +3134,270 @@ func _is_condition_met(condition) -> bool: return false +func _append_condition_progress_lines(condition_group, result: Array[String], mode: String, pending_parent := false) -> void: + if typeof(condition_group) == TYPE_ARRAY: + for condition in condition_group: + _append_condition_progress_lines(condition, result, mode, pending_parent) + return + if typeof(condition_group) != TYPE_DICTIONARY: + return + + var pending := pending_parent or not _condition_gate_open(condition_group) + if not _condition_gate_open(condition_group): + _append_after_event_progress_line(result, str(condition_group.get("after_event", ""))) + var condition_type := str(condition_group.get("type", "")) + if condition_type == "all" or condition_type == "any": + _append_condition_progress_lines(condition_group.get("conditions", []), result, mode, pending) + return + if condition_type == "all_units_defeated": + _append_all_units_progress_line(result, str(condition_group.get("team", "")), mode, pending) + elif condition_type == "all_enemies_defeated": + _append_all_units_progress_line(result, TEAM_ENEMY, mode, pending) + elif condition_type == "all_players_defeated": + _append_all_units_progress_line(result, TEAM_PLAYER, mode, pending) + elif condition_type == "any_unit_defeated": + _append_target_units_progress_line(result, condition_group.get("unit_ids", []), mode, pending) + elif condition_type == "any_officer_defeated": + _append_target_officers_progress_line( + result, + condition_group.get("officer_ids", []), + str(condition_group.get("team", "")), + mode, + pending + ) + elif condition_type == "unit_reaches_tile": + _append_destination_progress_line(result, condition_group, pending) + elif condition_type == "turn_limit" or condition_type == "turn_reached": + _append_turn_condition_progress_line(result, condition_group, mode, pending) + + +func _append_all_units_progress_line(result: Array[String], team: String, mode: String, pending := false) -> void: + var total := _count_deployed_units(team) + if total <= 0: + return + var living := _count_living_deployed_units(team) + if mode == "defeat": + _append_progress_line(result, "%s remaining %d/%d" % [_condition_team_label(team), living, total], pending) + return + _append_progress_line(result, "%s defeated %d/%d" % [_condition_team_label(team), total - living, total], pending) + + +func _append_target_units_progress_line(result: Array[String], unit_ids, mode: String, pending := false) -> void: + var counts := _count_condition_units(unit_ids) + var total := int(counts.get("total", 0)) + if total <= 0: + return + var defeated := int(counts.get("defeated", 0)) + if mode == "defeat" and total == 1: + var unit_name := _condition_unit_display_name(unit_ids[0]) + var status := "defeated" if defeated > 0 else "safe" + _append_progress_line(result, "%s %s" % [unit_name, status], pending) + return + var label := "Protected losses" if mode == "defeat" else "Target units defeated" + var suffix := " (defeat at 1)" if mode == "defeat" else "" + _append_progress_line(result, "%s %d/%d%s" % [label, defeated, total, suffix], pending) + + +func _append_target_officers_progress_line( + result: Array[String], + officer_ids, + team: String, + mode: String, + pending := false +) -> void: + var counts := _count_condition_officers(officer_ids, team) + var total := int(counts.get("total", 0)) + if total <= 0: + return + var defeated := int(counts.get("defeated", 0)) + if mode == "defeat" and total == 1: + var officer_name := _condition_officer_display_name(str(officer_ids[0]), team) + var status := "defeated" if defeated > 0 else "safe" + _append_progress_line(result, "%s %s" % [officer_name, status], pending) + return + var label := "Officer losses" if mode == "defeat" else "Target officers defeated" + var suffix := " (defeat at 1)" if mode == "defeat" else "" + _append_progress_line(result, "%s %d/%d%s" % [label, defeated, total, suffix], pending) + + +func _append_destination_progress_line(result: Array[String], condition: Dictionary, pending := false) -> void: + var target := _condition_cell(condition.get("pos", [])) + if not is_inside(target): + return + var status := "reached" if _unit_reaches_tile(condition) else "not reached" + _append_progress_line(result, "Destination %s: %s" % [_format_cell(target), status], pending) + + +func _append_turn_limit_progress_line(result: Array[String]) -> void: + var limit := get_turn_limit() + if limit <= 0: + return + var turns_left: int = maxi(0, limit - turn_number + 1) + _append_progress_line(result, "Turns left %d" % turns_left) + + +func _append_after_event_progress_line(result: Array[String], event_id: String) -> 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", {}) + var trigger_type := str(when.get("type", "")) + if trigger_type == "unit_reaches_tile": + _append_event_destination_progress_line(result, when) + elif trigger_type == "turn_start": + var target_turn := int(when.get("turn", 0)) + if target_turn > 0: + _append_progress_line(result, "Objective unlocks on Turn %d" % target_turn) + + +func _append_event_destination_progress_line(result: Array[String], when: Dictionary) -> void: + var target := _condition_cell(when.get("pos", [])) + if not is_inside(target): + return + var status := "reached" if _unit_reaches_tile(when) else "not reached" + _append_progress_line(result, "Reach %s: %s" % [_format_cell(target), status]) + + +func _append_turn_condition_progress_line(result: Array[String], condition: Dictionary, mode: String, pending := false) -> void: + var condition_type := str(condition.get("type", "")) + if condition_type == "turn_limit": + var limit := int(condition.get("turn", condition.get("limit", 0))) + if limit <= 0: + return + _append_progress_line(result, "Turns left %d" % maxi(0, limit - turn_number + 1), pending) + elif condition_type == "turn_reached": + var target_turn := int(condition.get("turn", 0)) + if target_turn <= 0: + return + if mode == "defeat": + var target_team := str(condition.get("team", "")) + var limit_turn := target_turn - 1 if target_team == TEAM_PLAYER else target_turn + _append_progress_line(result, "Turns left %d" % maxi(0, limit_turn - turn_number + 1), pending) + return + _append_progress_line(result, "Turn %d/%d" % [min(turn_number, target_turn), target_turn], pending) + + +func _append_progress_line(result: Array[String], text: String, pending := false) -> void: + if text.is_empty(): + return + if pending: + text += " (pending)" + if not result.has(text): + result.append(text) + + +func _count_deployed_units(team := "") -> int: + var count := 0 + for unit in units: + if not unit.get("deployed", true): + continue + if not team.is_empty() and unit.get("team", "") != team: + continue + count += 1 + return count + + +func _count_living_deployed_units(team := "") -> int: + var count := 0 + for unit in units: + if not unit.get("deployed", true): + continue + if not unit.get("alive", false): + continue + if not team.is_empty() and unit.get("team", "") != team: + continue + count += 1 + return count + + +func _count_condition_units(unit_ids) -> Dictionary: + var result := {"total": 0, "defeated": 0} + if typeof(unit_ids) != TYPE_ARRAY: + return result + var seen := {} + for unit_id in unit_ids: + var normalized_id := str(unit_id) + if normalized_id.is_empty() or seen.has(normalized_id): + continue + seen[normalized_id] = true + var unit := get_unit(normalized_id) + if unit.is_empty() or not unit.get("deployed", true): + continue + result["total"] = int(result["total"]) + 1 + if not unit.get("alive", false): + result["defeated"] = int(result["defeated"]) + 1 + return result + + +func _count_condition_officers(officer_ids, team := "") -> Dictionary: + var result := {"total": 0, "defeated": 0} + if typeof(officer_ids) != TYPE_ARRAY: + return result + var seen := {} + for officer_id in officer_ids: + var normalized_id := str(officer_id) + if normalized_id.is_empty() or seen.has(normalized_id): + continue + seen[normalized_id] = true + if not _condition_officer_exists(normalized_id, team): + continue + result["total"] = int(result["total"]) + 1 + if _is_officer_defeated(normalized_id, team): + result["defeated"] = int(result["defeated"]) + 1 + return result + + +func _condition_officer_exists(officer_id: String, team := "") -> bool: + if officer_id.is_empty(): + return false + for unit in units: + if not team.is_empty() and unit.get("team", "") != team: + continue + if not unit.get("deployed", true): + continue + if str(unit.get("officer_id", "")) == officer_id: + return true + return false + + +func _condition_unit_display_name(unit_id: String) -> String: + var unit := get_unit(unit_id) + if unit.is_empty(): + return unit_id + return str(unit.get("name", unit_id)) + + +func _condition_officer_display_name(officer_id: String, team := "") -> String: + for unit in units: + if not team.is_empty() and unit.get("team", "") != team: + continue + if not unit.get("deployed", true): + continue + if str(unit.get("officer_id", "")) == officer_id: + return str(unit.get("name", officer_id)) + var officer: Dictionary = data_catalog.officers.get(officer_id, {}) + return str(officer.get("name", officer_id)) + + +func _condition_team_label(team: String) -> String: + if team == TEAM_ENEMY: + return "Enemies" + if team == TEAM_PLAYER: + return "Allies" + return "Units" + + +func _event_by_id(event_id: String) -> Dictionary: + if event_id.is_empty(): + return {} + for event in battle_events: + if str(event.get("id", "")) == event_id: + return event + return {} + + func _is_turn_limit_exceeded(condition: Dictionary) -> bool: var limit := int(condition.get("turn", condition.get("limit", 0))) if limit <= 0: diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 047edd6..a92be29 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -232,7 +232,8 @@ func _create_hud() -> void: top_row.add_child(status_label) objective_label = Label.new() - objective_label.custom_minimum_size = Vector2(300, 40) + objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + objective_label.custom_minimum_size = Vector2(360, 48) top_row.add_child(objective_label) campaign_status_label = Label.new() @@ -259,6 +260,8 @@ func _create_hud() -> void: side_panel.add_child(side_column) var selected_row := HBoxContainer.new() + selected_row.custom_minimum_size = Vector2(420, 104) + selected_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL selected_row.add_theme_constant_override("separation", 8) side_column.add_child(selected_row) @@ -1457,7 +1460,7 @@ func _refresh_ranges() -> void: func _update_hud() -> void: status_label.text = state.get_status_text() - objective_label.text = String(state.objectives.get("victory", "Defeat all enemies.")) + objective_label.text = _format_objective_hud_text() campaign_status_label.text = campaign_state.get_progress_text() _update_cell_info() _update_forecast() @@ -1530,6 +1533,16 @@ func _update_hud() -> void: threat_button.disabled = not battle_started or state.battle_status != BattleState.STATUS_ACTIVE or campaign_complete_screen +func _format_objective_hud_text() -> String: + var text := String(state.objectives.get("victory", "Defeat all enemies.")) + var progress_text := state.get_objective_progress_text() + if progress_text.is_empty(): + return text + if text.is_empty(): + return progress_text + return "%s\n%s" % [text, progress_text] + + func _hovered_unit_for_hud_portrait() -> Dictionary: if not state.is_inside(hover_cell): return {} @@ -2428,6 +2441,11 @@ func _format_briefing_objectives() -> String: if not text.is_empty(): text += "\n" text += "Defeat: %s" % defeat + var tracker_text := state.get_objective_progress_text(true) + if not tracker_text.is_empty(): + if not text.is_empty(): + text += "\n" + text += "Tracker: %s" % tracker_text var rewards_text := _format_briefing_rewards() if not rewards_text.is_empty(): if not text.is_empty(): diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd new file mode 100644 index 0000000..3174bbc --- /dev/null +++ b/tools/smoke_objective_progress.gd @@ -0,0 +1,72 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") + + +func _init() -> void: + var failures: Array[String] = [] + + _check_contains( + failures, + "001 HUD progress", + _progress_text("res://data/scenarios/001_yellow_turbans.json", false), + "Enemies defeated 0/" + ) + _check_contains( + failures, + "001 briefing progress", + _progress_text("res://data/scenarios/001_yellow_turbans.json", true), + "Allies remaining" + ) + _check_contains( + failures, + "002 gated victory progress", + _progress_text("res://data/scenarios/002_sishui_gate.json", false), + "Objective unlocks on Turn 2" + ) + _check_contains( + failures, + "002 pending enemy progress", + _progress_text("res://data/scenarios/002_sishui_gate.json", false), + "Enemies defeated 0/" + ) + _check_contains( + failures, + "007 destination progress", + _progress_text("res://data/scenarios/007_xian_emperor_escort.json", false), + "Destination (14, 5): not reached" + ) + _check_contains( + failures, + "007 protected-unit risk progress", + _progress_text("res://data/scenarios/007_xian_emperor_escort.json", true), + "Imperial Envoy safe" + ) + _check_contains( + failures, + "045 gated destination progress", + _progress_text("res://data/scenarios/045_fancheng_relief.json", false), + "Reach (10, 5): not reached" + ) + + if failures.is_empty(): + print("objective progress smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _progress_text(path: String, include_defeat_risks: bool) -> String: + var state = BattleStateScript.new() + if not state.load_battle(path): + return "" % path + return state.get_objective_progress_text(include_defeat_risks) + + +func _check_contains(failures: Array[String], label: String, text: String, expected: String) -> void: + if text.contains(expected): + return + failures.append("%s expected `%s` in `%s`" % [label, expected, text]) diff --git a/tools/smoke_objective_progress.gd.uid b/tools/smoke_objective_progress.gd.uid new file mode 100644 index 0000000..7e98f1e --- /dev/null +++ b/tools/smoke_objective_progress.gd.uid @@ -0,0 +1 @@ +uid://pb6hjahii62g