From e76d7e3a7e20e125d22cf82c442da764628ba336 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 13:56:02 +0900 Subject: [PATCH] Add scripted event withdrawals --- README.md | 2 +- data/scenarios/009_xiapi_siege.json | 4 + docs/ARCHITECTURE.md | 2 +- docs/DATA_MODEL.md | 5 +- docs/GODOT_4_6_MIGRATION.md | 1 + docs/ROADMAP.md | 4 +- scripts/core/battle_state.gd | 28 +++++++ scripts/scenes/battle_scene.gd | 2 + tools/smoke_event_withdraw.gd | 117 ++++++++++++++++++++++++++++ tools/validate_data.ps1 | 35 ++++++++- 10 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 tools/smoke_event_withdraw.gd diff --git a/README.md b/README.md index 677ed01..d57ce95 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 with HUD notices, inventory pickups, and reinforcements. +- Scenario events for battle start, battle-begin dialogue, turn start, movement-triggered ambushes, objective updates with HUD notices, inventory pickups, reinforcements, and scripted unit withdrawals. - 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/009_xiapi_siege.json b/data/scenarios/009_xiapi_siege.json index 6df6375..56e526e 100644 --- a/data/scenarios/009_xiapi_siege.json +++ b/data/scenarios/009_xiapi_siege.json @@ -258,6 +258,10 @@ "victory": "Defeat Lu Bu, Chen Gong, and the remaining Xiapi defenders.", "defeat": "Cao Cao is defeated or the battle reaches Turn 13." }, + { + "type": "withdraw_units", + "unit_ids": ["lu_bu_flank_rider_north", "lu_bu_flank_rider_south"] + }, { "type": "spawn_deployments", "deployments": [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa802b2..504f4ad 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 with HUD notices, 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, deploy reinforcements, or withdraw units from the battlefield. 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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index d650cce..310120b 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`. `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. +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`, `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. ```json { @@ -487,6 +487,7 @@ Scenario `events` support `battle_start`, `battle_begin`, `turn_start`, and move }, { "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": "spawn_deployment", "deployment": { @@ -563,4 +564,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 item grants, condition unit references, destination condition 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 item grants, event withdrawal unit ids, condition unit references, destination condition 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 82fb306..d4d7297 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -50,6 +50,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - 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. +- `withdraw_unit` and `withdraw_units` scenario events remove living targets from the battlefield without marking them defeated. - 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 abf6243..abd83ce 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,10 +43,10 @@ - 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, and objective-change notices. -- Mid-battle map/action events have a first action system with movement ambushes. +- 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. -- Reinforcements, objective changes with HUD/log feedback, and inventory pickup events exist in a basic form. +- Reinforcements, scripted unit withdrawals, 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 453a58e..4f8e6ca 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -3648,6 +3648,15 @@ func _execute_event_action(action: Dictionary, trigger_unit: Dictionary = {}) -> return for deployment in deployments: _spawn_event_deployment(deployment) + elif action_type == "withdraw_unit": + _withdraw_event_unit(str(action.get("unit_id", action.get("id", "")))) + elif action_type == "withdraw_units": + var unit_ids = action.get("unit_ids", []) + if typeof(unit_ids) != TYPE_ARRAY: + push_error("Skipping malformed withdrawal list in %s." % battle_id) + return + for unit_id in unit_ids: + _withdraw_event_unit(str(unit_id)) func _grant_event_items(item_entries, trigger_unit: Dictionary = {}) -> void: @@ -3781,6 +3790,25 @@ func _spawn_event_deployment(deployment) -> bool: return true +func _withdraw_event_unit(unit_id: String) -> bool: + var normalized_id := unit_id.strip_edges() + if normalized_id.is_empty(): + push_error("Skipping event withdrawal with no unit_id in %s." % battle_id) + return false + var unit := get_unit(normalized_id) + if unit.is_empty() or not unit.get("deployed", true) or not unit.get("alive", false): + return false + if selected_unit_id == normalized_id: + selected_unit_id = "" + _emit_combat_feedback(unit, "WITHDRAW", "fade") + unit["deployed"] = false + unit["acted"] = true + unit["moved"] = true + _emit_log("%s withdraws from the battlefield." % unit.get("name", normalized_id)) + _notify_changed() + return true + + func _reset_team_actions(team: String) -> void: _apply_phase_status_effects_for_team(team) _check_battle_status() diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 1a8ea5d..ef7d576 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -835,6 +835,8 @@ func _play_log_sfx(message: String) -> void: _play_sfx("guard_block") elif message.begins_with("Objective updated:") or message.begins_with("Defeat condition updated:"): _play_ui_confirm() + elif message.ends_with(" withdraws from the battlefield."): + _play_ui_confirm() elif message.contains(" attacks ") or message.contains(" counterattacks "): if message.contains(" for "): _play_sfx("hit_heavy") diff --git a/tools/smoke_event_withdraw.gd b/tools/smoke_event_withdraw.gd new file mode 100644 index 0000000..c26aaa2 --- /dev/null +++ b/tools/smoke_event_withdraw.gd @@ -0,0 +1,117 @@ +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/009_xiapi_siege.json"): + push_error("Could not load Xiapi smoke scenario.") + quit(1) + return + + _check_single_withdraw(state, failures) + _check_multi_withdraw(state, failures) + + if failures.is_empty(): + print("event withdraw smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_single_withdraw(state, failures: Array[String]) -> void: + var logs := [] + var feedback := [] + 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}) + ) + + _spawn_smoke_unit(state, "withdraw_smoke_enemy", "Smoke Raider", Vector2i(4, 4)) + logs.clear() + feedback.clear() + var before_living: int = state.get_living_units("enemy").size() + state._execute_event_action({"type": "withdraw_unit", "unit_id": "withdraw_smoke_enemy"}) + var unit: Dictionary = state.get_unit("withdraw_smoke_enemy") + if unit.is_empty(): + failures.append("single withdraw smoke unit disappeared from state") + return + if bool(unit.get("deployed", true)): + failures.append("single withdraw unit should no longer be deployed") + if not bool(unit.get("alive", false)): + failures.append("single withdraw unit should stay alive for non-defeat retreat semantics") + if not state.get_unit_at(Vector2i(4, 4)).is_empty(): + failures.append("single withdraw unit should no longer occupy its map cell") + if state.get_living_units("enemy").size() != before_living - 1: + failures.append("single withdraw should remove one living enemy from battle counts") + _check_log_contains(failures, logs, "Smoke Raider withdraws from the battlefield.") + _check_feedback_contains(failures, feedback, "withdraw_smoke_enemy", "WITHDRAW", "fade") + + +func _check_multi_withdraw(state, failures: Array[String]) -> void: + _spawn_smoke_unit(state, "withdraw_smoke_north", "North Raider", Vector2i(5, 4)) + _spawn_smoke_unit(state, "withdraw_smoke_south", "South Raider", Vector2i(6, 4)) + state._execute_event_action({ + "type": "withdraw_units", + "unit_ids": ["withdraw_smoke_north", "withdraw_smoke_south"] + }) + for unit_id in ["withdraw_smoke_north", "withdraw_smoke_south"]: + var unit: Dictionary = state.get_unit(unit_id) + if unit.is_empty(): + failures.append("multi withdraw unit missing from state: %s" % unit_id) + continue + if bool(unit.get("deployed", true)): + failures.append("multi withdraw unit should no longer be deployed: %s" % unit_id) + if not bool(unit.get("alive", false)): + failures.append("multi withdraw unit should stay alive: %s" % unit_id) + + +func _spawn_smoke_unit(state, unit_id: String, unit_name: String, cell: Vector2i) -> void: + state._execute_event_action({ + "type": "spawn_deployment", + "deployment": { + "unit_id": unit_id, + "name": unit_name, + "class_id": "cavalry", + "team": "enemy", + "level": 8, + "pos": [cell.x, cell.y], + "base": {"hp": 40, "atk": 15, "def": 8, "agi": 11} + } + }) + + +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_feedback_contains( + failures: Array[String], + feedback: Array, + expected_unit_id: String, + expected_text: String, + expected_kind: String +) -> void: + 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 + failures.append("expected feedback `%s/%s/%s` in `%s`" % [ + expected_unit_id, + expected_text, + expected_kind, + feedback + ]) diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index abb229c..637576b 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") -$validActions = @("log", "dialogue", "set_objective", "grant_item", "grant_items", "spawn_deployment", "spawn_deployments") +$validActions = @("log", "dialogue", "set_objective", "grant_item", "grant_items", "spawn_deployment", "spawn_deployments", "withdraw_unit", "withdraw_units") $validEffects = @("heal_hp", "heal_mp", "cure_status") $validItemKinds = @("weapon", "armor", "accessory", "consumable") $validItemRarities = @("common", "named") @@ -554,6 +554,32 @@ function Check-Event-Grant-Item($Entry, [string]$Context) { } } +function Check-Event-Withdraw-Unit-Id([string]$UnitId, $KnownUnitIds, [string]$Context) { + if ([string]::IsNullOrWhiteSpace($UnitId)) { + Fail "$Context has empty unit id." + } + if (-not $KnownUnitIds.Contains($UnitId)) { + Fail "$Context references unknown unit: $UnitId" + } +} + +function Check-Event-Withdraw-Units($UnitIds, $KnownUnitIds, [string]$Context) { + if ($null -eq $UnitIds -or -not ($UnitIds -is [System.Array])) { + Fail "$Context unit_ids must be an array." + } + if (@($UnitIds).Count -le 0) { + Fail "$Context unit_ids must not be empty." + } + $seenWithdrawals = New-Object System.Collections.Generic.HashSet[string] + foreach ($unitIdValue in @($UnitIds)) { + $unitId = [string]$unitIdValue + Check-Event-Withdraw-Unit-Id $unitId $KnownUnitIds $Context + if (-not $seenWithdrawals.Add($unitId)) { + Fail "$Context has duplicate unit: $unitId" + } + } +} + function Check-Class-Equipment-Slots() { $weaponTypes = @() $armorTypes = @() @@ -1757,6 +1783,13 @@ foreach ($scenario in $campaign.scenarios) { Check-Event-Grant-Item $entry "Scenario $scenarioId event $eventId grant_items" } } + if ($actionType -eq "withdraw_unit") { + $unitId = [string](Get-Prop $action "unit_id" (Get-Prop $action "id" "")) + Check-Event-Withdraw-Unit-Id $unitId $knownUnitIds "Scenario $scenarioId event $eventId withdraw_unit" + } + if ($actionType -eq "withdraw_units") { + Check-Event-Withdraw-Units (Get-Prop $action "unit_ids" $null) $knownUnitIds "Scenario $scenarioId event $eventId withdraw_units" + } } }