Initial Godot tactical RPG prototype

This commit is contained in:
2026-06-17 23:06:18 +09:00
commit b08c2ac37e
37 changed files with 13792 additions and 0 deletions

60
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,60 @@
# Architecture
## Principles
- Keep rules data-driven.
- Keep battle state independent from rendering.
- Build one complete vertical slice before expanding systems.
- Prefer simple Godot nodes and GDScript until a system proves it needs more structure.
## Current Layout
- `project.godot`: Godot 4 project configuration.
- `scenes/battle_scene.tscn`: Main playable battle scene.
- `scripts/core/data_catalog.gd`: JSON definition loader and deployment hydrator.
- `scripts/core/campaign_state.gd`: Thin campaign save state for gold, inventory, roster, completed scenarios, and campaign flags.
- `scripts/core/battle_state.gd`: Battle rules, unit state, turns, AI, and victory checks.
- `scripts/scenes/battle_scene.gd`: Rendering, input handling, and HUD wiring.
- `data/campaign/campaign.json`: Campaign order and scenario paths.
- `data/defs/*.json`: Officers, classes, terrain, items, and skills.
- `data/scenarios/*.json`: Scenario definitions.
## Data Files
- `data/defs/classes.json`: Class stats, movement types, skill access.
- `data/defs/officers.json`: Named officer base stats and growth.
- `data/defs/items.json`: Weapons, armor, accessories, and consumables.
- `data/defs/skills.json`: MP tactic definitions for damage, healing, target rules, and range.
- `data/defs/terrain.json`: Terrain colors, defense, avoid, and movement costs.
- `data/campaign/campaign.json`: Scenario sequence, titles, and resource paths.
- `data/scenarios/*.json`: Dialogue, deployments, rewards, and branching flags.
## Battle Loop
1. Load battle data.
2. Hydrate deployments through `DataCatalog`.
3. Dispatch battle-start scenario events.
4. Show scenario briefing, including the optional pre-battle shop, Armory, Roster, and Formation setup.
5. Player selects a unit.
6. Briefing completion dispatches battle-begin events, including opening dialogue.
7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, and power. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP.
8. Player ends turn.
9. Enemy AI moves, attacks, and can cast damage or healing 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 or deploy reinforcements.
12. On 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 summarizes rewards plus saved player level-ups and promotions.
15. If the scenario defines post-battle choices, the result panel waits for the player to select one, then saves the selected flags and optional choice rewards before enabling the next battle.
16. The result panel can load the next scenario, passing saved roster and inventory overlays back into `BattleState`.
17. Once all campaign scenarios are complete, the battle scene shows a campaign completion panel instead of loading another map.
## Save Boundary
`BattleState` owns tactical runtime fields. `CampaignState` saves only campaign-facing data: completed scenario ids, gold, inventory counts, joined officers, campaign flags, and a player roster snapshot. It does not save map positions, pre-battle Formation choices, action flags, or transient battle occupancy.
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. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Purchases are campaign transactions: they spend saved gold 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 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, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved 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. Already completed scenarios can be replayed without granting duplicate rewards, choices, or replay inventory consumption; pre-battle Shop, Armory, Roster, and Formation are disabled on completed-scenario replays to avoid side effects.
The `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing.

378
docs/DATA_MODEL.md Normal file
View File

@@ -0,0 +1,378 @@
# Data Model Plan
The prototype now uses a JSON catalog that hydrates compact scenario deployments into the same runtime unit dictionaries `BattleState` already understands. Legacy inline `units` remain supported for quick experiments.
## Files
```text
data/defs/officers.json
data/defs/classes.json
data/defs/terrain.json
data/defs/items.json
data/defs/skills.json
data/campaign/campaign.json
data/scenarios/001_yellow_turbans.json
data/scenarios/002_sishui_gate.json
data/scenarios/003_xingyang_ambush.json
data/scenarios/004_qingzhou_campaign.json
data/scenarios/005_puyang_raid.json
data/scenarios/006_dingtao_counterattack.json
data/scenarios/007_xian_emperor_escort.json
data/scenarios/008_wan_castle_escape.json
data/scenarios/009_xiapi_siege.json
data/scenarios/010_white_horse_relief.json
data/scenarios/011_yan_ford_pursuit.json
data/scenarios/012_guandu_showdown.json
data/scenarios/013_wuchao_raid.json
data/scenarios/014_cangting_pursuit.json
data/scenarios/015_ye_campaign.json
data/scenarios/016_ye_siege.json
data/scenarios/017_ye_surrender.json
```
## Campaign
The campaign file defines scenario order, resource paths, and officers who are available at the start of a new campaign.
```json
{
"id": "cao_cao_campaign",
"title": "Cao Cao Campaign",
"start_scenario": "001_yellow_turbans",
"initial_joined_officers": ["cao_cao", "xiahou_dun"],
"scenarios": [
{ "id": "001_yellow_turbans", "title": "Yingchuan Skirmish", "path": "res://data/scenarios/001_yellow_turbans.json" },
{ "id": "002_sishui_gate", "title": "Sishui Gate Vanguard", "path": "res://data/scenarios/002_sishui_gate.json" },
{ "id": "003_xingyang_ambush", "title": "Xingyang Ambush", "path": "res://data/scenarios/003_xingyang_ambush.json" },
{ "id": "004_qingzhou_campaign", "title": "Qingzhou Campaign", "path": "res://data/scenarios/004_qingzhou_campaign.json" },
{ "id": "005_puyang_raid", "title": "Puyang Raid", "path": "res://data/scenarios/005_puyang_raid.json" },
{ "id": "006_dingtao_counterattack", "title": "Dingtao Counterattack", "path": "res://data/scenarios/006_dingtao_counterattack.json" },
{ "id": "007_xian_emperor_escort", "title": "Xian Emperor Escort", "path": "res://data/scenarios/007_xian_emperor_escort.json" },
{ "id": "008_wan_castle_escape", "title": "Wan Castle Escape", "path": "res://data/scenarios/008_wan_castle_escape.json" },
{ "id": "009_xiapi_siege", "title": "Xiapi Siege", "path": "res://data/scenarios/009_xiapi_siege.json" },
{ "id": "010_white_horse_relief", "title": "White Horse Relief", "path": "res://data/scenarios/010_white_horse_relief.json" },
{ "id": "011_yan_ford_pursuit", "title": "Yan Ford Pursuit", "path": "res://data/scenarios/011_yan_ford_pursuit.json" },
{ "id": "012_guandu_showdown", "title": "Guandu Showdown", "path": "res://data/scenarios/012_guandu_showdown.json" },
{ "id": "013_wuchao_raid", "title": "Wuchao Raid", "path": "res://data/scenarios/013_wuchao_raid.json" },
{ "id": "014_cangting_pursuit", "title": "Cangting Pursuit", "path": "res://data/scenarios/014_cangting_pursuit.json" },
{ "id": "015_ye_campaign", "title": "Ye Campaign", "path": "res://data/scenarios/015_ye_campaign.json" },
{ "id": "016_ye_siege", "title": "Ye Siege", "path": "res://data/scenarios/016_ye_siege.json" },
{ "id": "017_ye_surrender", "title": "Ye Surrender", "path": "res://data/scenarios/017_ye_surrender.json" }
]
}
```
## Officers
Officers hold identity, personal base stats, initial class, and starting equipment.
```json
{
"cao_cao": {
"name": "Cao Cao",
"faction": "wei",
"class_id": "hero",
"level": 1,
"exp": 0,
"base": { "hp": 38, "mp": 10, "atk": 13, "def": 8, "int": 12, "agi": 8 },
"growth_bonus": { "atk": 1, "int": 1 },
"equipment": { "weapon": "bronze_sword", "armor": "cloth_robe", "accessory": null }
}
}
```
## Classes
Classes provide movement, min/max attack range, skill access, growth grades, class bonuses, equipment permissions, and promotion routes.
```json
{
"cavalry": {
"name": "Cavalry",
"tier": 1,
"move_type": "mounted",
"move": 5,
"attack_range": [1, 1],
"skills": [],
"growth": { "hp": "A", "mp": "D", "atk": "A", "def": "B", "int": "D", "agi": "B" },
"base_bonus": { "hp": 4, "atk": 2, "def": 0 },
"equipment_slots": {
"weapon": ["spear", "sword"],
"armor": ["light_armor", "heavy_armor"],
"accessory": ["accessory"]
},
"promotion": { "level": 15, "to": "elite_cavalry" }
}
}
```
`attack_range` is `[min, max]` in Manhattan distance. A range of `[2, 2]` means the unit cannot attack adjacent targets.
`promotion.level` and `promotion.to` define an automatic class promotion route. When a unit reaches the threshold during battle, the runtime updates class name, movement, movement type, growth, class skills, attack range, and class base-bonus deltas. The promoted `class_id` and derived roster fields are captured in the campaign roster snapshot, and the next battle rehydrates class-derived fields from the saved `class_id`. Promotion targets must be higher-tier classes and must keep all source equipment slot permissions unless runtime auto-unequip logic is added later. The current core routes cover hero, cavalry, infantry, archer, warrior, and strategist first-tier classes.
`warrior` is the first player-facing axe class. It gives Dian Wei a heavy melee role without requiring unique weapon-instance logic yet.
`strategist` is the first MP-focused enemy-capable class. It uses the same `skills` list as player units, so scenario designers can add tactical pressure without adding new runtime unit fields.
## Skills
Skills define MP tactics. A class, officer, or scenario deployment can add skill ids to the runtime unit. `range` is `[min, max]` in Manhattan distance and can use `0` as the minimum for self-targeting support skills. Enemy AI can use `damage` and `heal` skills when it has enough MP.
```json
{
"fire_tactic": {
"name": "Fire",
"kind": "damage",
"target": "enemy",
"mp_cost": 4,
"range": [1, 3],
"power": 8,
"stat": "int"
}
}
```
The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, and `great_mend`. Hero and strategist classes get low-cost Spark alongside Fire/Mend, promoted command/advisor classes add Great Mend, and late scenario deployments can add stronger pressure skills such as Blaze without changing their base class.
## Terrain
Terrain keeps compact map characters but moves costs and bonuses out of code. `defense` reduces physical and damage-tactic damage taken on that tile, while `avoid` reduces physical attack hit chance. Per-move-type costs can be added before pathfinding becomes more complex.
```json
{
"G": {
"id": "plain",
"name": "Plain",
"color": "#6d9e4f",
"defense": 0,
"avoid": 0,
"move_cost": { "foot": 1, "mounted": 1, "archer": 1, "water": 99 }
}
}
```
Physical hit chance is `90 + attacker AGI - target AGI - terrain avoid`, clamped to 25-100. Skill damage currently uses terrain defense but does not roll against terrain avoid.
## Items
Items start as static bonuses and consumable effects. Equipment leveling should later live on save-game item instances, not base item definitions. Consumables and unequipped weapon, armor, and accessory rewards are stored as campaign inventory counts and copied into the battle state when a scenario loads.
```json
{
"bronze_sword": {
"name": "Bronze Sword",
"kind": "weapon",
"weapon_type": "sword",
"range": [1, 1],
"bonuses": { "atk": 3 },
"effects": [],
"price": 300
}
}
```
Weapon `range` also uses `[min, max]`. When class and equipment both provide range, the runtime unit keeps the lowest minimum and highest maximum. Accessories use `kind: "accessory"` with an `accessory_type` checked against the class `equipment_slots.accessory` list; `war_drum` and `imperial_seal` are the first branch-choice accessory rewards. Current equipment inventory is stackable stock only; unique equipment instances, durability, and enhancement are planned later. `war_axe` is a stronger axe reward for the Warrior/Bandit weapon family.
Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP and `heal_mp` restores MP to a valid target; `Bean` and `Wine` are the first examples. Item use consumes the acting unit's action and only commits to the save file on victory. Weapons, armor, and accessories can be granted through `rewards.items`; they are shown in inventory, can be equipped through the Equip menu before the unit moves or acts, and are not usable from the battle item menu.
Scenario `shop.items` controls which positive-price items appear in the current pre-battle shop. `shop.conditional_items` can append extra stock when saved campaign flags match. Purchases spend campaign gold, increment the campaign inventory count, save immediately, and then refresh the battle inventory copy if a scenario briefing is already loaded. Later campaign data should add finite stock counts, unlock flags, and sell-back rules.
The pre-battle Armory operates on the currently loaded player deployments and the copied battle inventory, then saves the changed roster entries and inventory counts back into `CampaignState`. Unlike in-battle equipment swaps, Armory changes persist immediately and survive defeat or restart. Completed-scenario replays keep Shop and Armory disabled so replay setup cannot alter the campaign save.
Scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` control the first pre-battle Roster selection pass. Required officers and required scenario units always deploy, optional player deployments can move between sortie and reserve, and reserve units do not participate in map occupancy, targeting, or living-unit victory checks. If `roster` is omitted, all player deployments sortie as before.
Scenario-only protected units can use `team: "player"`, `controllable: false`, `persist_progression: false`, and an optional `ai_target_priority`. They remain valid targets for enemies, healing, and defeat conditions, but the player cannot select, move, attack, change equipment, or place them in Formation, and they are omitted from campaign roster progression snapshots. `ai_target_priority` is a non-negative scenario hint that makes enemy movement and damage-skill AI treat that unit as a more attractive target.
Scenario `formation.cells` controls the highlighted starting cells available during pre-battle Formation. These choices only change the current battle's unit positions; they are not saved to campaign state. If a scenario omits `formation`, player deployment positions become the default formation cells.
## Scenarios
Scenarios should use deployments rather than full inline unit stat blocks. `DataCatalog` will resolve officer, class, and item references into runtime unit dictionaries. Scenarios may also define briefing text, visible objective text, battle conditions, and victory rewards.
```json
{
"id": "001_yellow_turbans",
"name": "Yingchuan Skirmish",
"objectives": {
"victory": "Defeat all Yellow Turban units.",
"defeat": "All allied units are defeated."
},
"conditions": {
"victory": { "type": "all_units_defeated", "team": "enemy" },
"defeat": { "type": "all_units_defeated", "team": "player" }
},
"briefing": {
"title": "Yingchuan Skirmish",
"location": "Yingchuan, 184 CE",
"lines": ["Yellow Turban rebels have gathered near Yingchuan."],
"conditional_lines": [
{
"campaign_flags": { "pursued_dong_zhuo": true },
"lines": ["The army arrives tired from a rapid pursuit."]
}
]
},
"shop": {
"items": ["bean"],
"conditional_items": [
{
"campaign_flags": { "regrouped_after_sishui": true },
"items": ["leather_armor"]
}
]
},
"roster": {
"max_units": 3,
"required_officers": ["cao_cao"],
"required_units": ["protected_envoy"]
},
"formation": {
"cells": [[1, 3], [1, 4], [2, 3], [2, 4]]
},
"deployments": [
{ "unit_id": "cao_cao", "officer_id": "cao_cao", "team": "player", "pos": [1, 3] },
{ "unit_id": "protected_envoy", "name": "Protected Envoy", "class_id": "infantry", "team": "player", "controllable": false, "persist_progression": false, "ai_target_priority": 6, "pos": [1, 4] },
{ "unit_id": "xiahou_yuan_ch2", "officer_id": "xiahou_yuan", "team": "player", "requires_joined": true, "pos": [2, 3] },
{
"unit_id": "yellow_turban_1",
"name": "Zhang Mancheng",
"class_id": "bandit",
"team": "enemy",
"level": 1,
"pos": [7, 2],
"base": { "hp": 34, "atk": 11, "def": 5 }
}
],
"rewards": {
"gold": 300,
"items": ["bean"],
"join_officers": ["xiahou_yuan"],
"leave_officers": []
},
"post_battle_dialogue": [
{ "speaker": "Cao Cao", "text": "This victory is only the first fire." }
],
"post_battle_choices": [
{
"id": "pursue_dong_zhuo",
"label": "Pursue Dong Zhuo",
"description": "Cao Cao presses the retreating army.",
"set_flags": { "pursued_dong_zhuo": true }
}
]
}
```
`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.
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.
Supported condition forms:
```json
{ "type": "all_units_defeated", "team": "enemy" }
{ "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }
{ "type": "any_unit_defeated", "unit_ids": ["cao_cao_ch2"] }
{ "type": "turn_reached", "turn": 9, "team": "player" }
{ "type": "turn_limit", "turn": 8 }
{ "type": "unit_reaches_tile", "team": "player", "officer_ids": ["cao_cao"], "pos": [13, 4] }
{ "type": "all", "conditions": [{ "type": "all_units_defeated", "team": "enemy" }] }
{ "type": "any", "conditions": [{ "type": "any_officer_defeated", "officer_ids": ["cao_cao"] }] }
```
Conditions may include `after_event` to stay inactive until a one-shot event has fired. This is useful for reinforcement battles where the player should not win before the reinforcement event occurs.
`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 a zero-based `pos` coordinate. 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.
`post_battle_dialogue` plays once after victory and before the victory result panel opens. Lines may be plain strings or objects with `speaker` and non-empty `text`.
`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. `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`, `spawn_deployment`, and `spawn_deployments`.
```json
{
"id": "turn_2_warning",
"once": true,
"when": {
"type": "turn_start",
"team": "enemy",
"turn": 2,
"campaign_flags": { "pursued_dong_zhuo": true }
},
"actions": [
{ "type": "log", "text": "Enemy reinforcements are approaching." },
{
"type": "dialogue",
"lines": [
{ "speaker": "Cao Cao", "text": "Hold formation." }
]
},
{ "type": "set_objective", "victory": "Defeat the vanguard and reinforcements." },
{
"type": "spawn_deployment",
"deployment": {
"unit_id": "enemy_reinforcement_1",
"name": "Enemy Rider",
"class_id": "cavalry",
"team": "enemy",
"level": 2,
"pos": [11, 3],
"base": { "hp": 36, "atk": 12, "def": 6 }
}
}
]
}
```
Movement-triggered ambushes use the same event action shape:
```json
{
"id": "midroad_ambush",
"once": true,
"when": { "type": "unit_reaches_tile", "team": "player", "pos": [7, 4] },
"actions": [
{ "type": "dialogue", "lines": [{ "speaker": "Scout", "text": "Ambush!" }] },
{
"type": "spawn_deployment",
"deployment": {
"unit_id": "ambush_guard",
"name": "Ambush Guard",
"class_id": "infantry",
"team": "enemy",
"level": 7,
"pos": [6, 2]
}
}
]
}
```
`briefing.conditional_lines`, `shop.conditional_items`, and `when.campaign_flags` all use exact flag matching. When present, every listed flag must match the saved campaign flag value for that block or event to apply. Use separate blocks or events for OR-style branches. The validator requires referenced flags to have been introduced by an earlier `post_battle_choices.set_flags` entry in campaign order.
Event-spawned units use the same compact deployment shape as scenario starting units. Spawn attempts are skipped if the unit id already exists, the target cell is occupied, the cell is outside the map, or the unit cannot stand on that terrain. Reinforcements that arrive during their own team's phase wait until their next phase before acting.
## Current Implementation
`scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, and `growth_bonus` so the battle resolver can handle counterattacks, MP tactics, EXP, level-ups, and enemy tactic AI without changing scenario files.
`scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, and player roster progression. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, and post-battle choices are guarded by completed scenario id so restarting a finished battle does not duplicate gold, items, membership changes, or branch decisions. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves.
After victory, `CampaignState` advances `current_scenario_id` to the next entry in `data/campaign/campaign.json`. The battle scene passes `CampaignState.get_roster_overrides()`, `CampaignState.get_inventory_snapshot()`, `CampaignState.get_flags_snapshot()`, and `CampaignState.get_joined_officers_snapshot()` into `BattleState.load_battle()`, so officers can keep level/EXP/stat progression, equipped gear, consumable/equipment stock counts, recruitment gates, and branch flags into the next scenario.
During a battle, item consumption and equipment swaps affect `BattleState.battle_inventory`. `CampaignState.apply_battle_result()` commits that inventory snapshot only for a first-time victory, then adds the victory rewards. Equipped gear is stored in the player roster snapshot together with the current derived stats; a later stat-model refactor should split base stats, growth, and equipment bonuses.
`BattleState` records player-only `progression_events` when a unit levels up or promotes. `CampaignState.apply_battle_result()` includes those events in the victory summary only when the first-time victory save succeeds, so completed-scenario replays and failed saves do not present unsaved growth as campaign progress.
Physical attacks grant normal attack/counter EXP only on hit after the attack/counter exchange resolves. A missed physical attack or counter grants a small miss EXP value, while defeat bonuses still require the attack to hit and defeat the target.
Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()`. A successful purchase updates campaign gold and inventory in the save file immediately, then calls `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails.
When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the save file and resets `current_scenario_id` to `start_scenario`.
Run `tools/validate_data.ps1` after data edits to check campaign paths, map dimensions, terrain keys, class/officer/item/skill references, item effect names and amounts, equipment slot compatibility including accessory types, promotion routes and promotion equipment compatibility, condition/event names, 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.

View File

@@ -0,0 +1,56 @@
# Godot 4.6 Migration
This project targets Godot 4 and is being moved toward Godot 4.6 as the active editor baseline.
## Current Risk Profile
- The project is a simple Godot 4 `Node2D` application with data-driven JSON content.
- There are no addons, imported art assets, shaders, TileMaps, 3D physics scenes, or export presets yet.
- The current renderer is explicitly set to `gl_compatibility`, so new-project rendering defaults in Godot 4.6 should not silently change the existing project renderer.
- `project.godot` still advertises `config/features=PackedStringArray("4.2")`; let the Godot 4.6 editor update project files instead of editing generated scene/resource formats by hand.
## Before Opening In 4.6
1. Create a backup or initialize version control before the editor writes migration changes.
2. Run the data/readiness check:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readiness.ps1
```
3. If the Godot executable is not on `PATH`, set `GODOT_BIN` for command-line checks:
```powershell
$env:GODOT_BIN = "C:\Path\To\Godot_v4.6-stable_win64.exe"
```
## Editor Migration Steps
1. Open the project folder in Godot 4.6.
2. Let the editor import the project and accept any project-file migration prompt.
3. Run `Project > Tools > Upgrade Project Files...`.
4. Save, close, and reopen the project once.
5. Run the readiness check again. If `GODOT_BIN` is set, include the optional headless editor import:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readiness.ps1 -RunGodotImport
```
## Manual Smoke Test
- Main scene starts without script errors.
- Current campaign save loads or a new campaign starts cleanly.
- Briefing opens and can enter battle.
- Shop, Armory, Roster, and Formation open and close.
- A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear.
- Enemy turn advances and AI acts.
- Victory and defeat panels still appear.
- Post-battle dialogue and post-battle choices still block progression until a choice is saved.
- Next battle loads with saved roster, inventory, joined officers, and campaign flags.
- New Campaign clears the save and returns to the opening scenario.
## Watch Items
- If the editor updates `config/features`, review the diff before making content changes.
- If UI colors or canvas rendering look different, compare the battle board, overlays, and panels before changing art direction.
- If a save file behaves oddly after migration, test both a fresh campaign and an existing `user://campaign_save.json`.

92
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,92 @@
# Roadmap
## Milestone 0: Project Foundation
- Godot 4 project opens and runs.
- Main battle scene loads a JSON battle.
- Rules and presentation are separated enough to keep iterating.
## Milestone 1: Playable Battle Prototype
- Scenario briefing before battle.
- Select player units.
- Show move and attack ranges.
- Show hover tile and unit information.
- Show basic damage forecast.
- Move, attack, wait, and end turn.
- Enemy units take AI turns with movement, attacks, and basic tactic use.
- Victory and defeat states are visible.
- Scenario-defined victory and defeat conditions exist.
- Destination victory conditions and objective map markers exist.
- Turn-limit conditions and HUD turn-limit display exist.
- Victory reward summary is visible.
- Next-battle flow exists across the current seventeen-scenario campaign.
- Campaign completion and new-campaign reset exist.
- Reward inventory can be brought into the next battle.
## Milestone 2: Cao Cao Zhuan-Style Combat Depth
- Unit classes and class growth tables. Basic version exists.
- Automatic class promotion routes. Basic level-threshold promotion exists for core first-tier battle classes.
- Weapon, armor, and accessory slots. Basic stat bonuses exist.
- Equipment rewards can be stored, displayed, and swapped from the battle HUD.
- Terrain movement, defense, and avoid modifiers affect combat.
- Skills, tactics, and MP. Multiple single-target damage/heal tactics, a skill list menu with range/power hints, and enemy tactic AI exist.
- Consumable item use. Basic global inventory, Bean HP recovery, Wine MP recovery, item menu, preview, and target overlay exist.
- Counterattacks exist. Support effects are still planned.
- Battle EXP, level-ups, class promotion, and campaign roster persistence exist.
## Milestone 3: Scenario Layer
- Pre-battle briefing exists. 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.
- Post-battle dialogue exists before the victory result panel.
- Reinforcements and objective changes exist in a basic form.
- Post-battle reward save, campaign choice flags, and first flag-driven briefing/shop/event branches exist in a basic form. Rich reward screens and wider branch support are still planned.
## Milestone 4: Campaign Progression
- Party roster persistence has a first save-file skeleton.
- Level, experience, and equipment persistence.
- Chapter selection and save/load.
- Linear seventeen-scenario campaign order exists.
- Save reset exists.
- Victory rewards, consumable counts, equipment stock, and equipped gear persist.
- A basic pre-battle shop with scenario-specific stock exists. Sell-back and richer item management are still planned.
- A basic pre-battle Armory exists for saved equipment changes before combat.
- A basic pre-battle Roster exists for required officers, required scenario units, optional officers, and sortie limits.
- A basic pre-battle Formation screen exists for scenario-defined starting cells.
- Officer join/leave rewards, choice membership changes, and joined-officer deployment gates exist in a basic form.
- Completed-save officer membership reconciliation exists for newly added join/leave rewards.
- Post-battle choice ids are saved in a campaign ledger for safer branch reconciliation.
- Pending post-battle choices survive reloads without replaying victory rewards.
- Fourth-scenario content exists with Qingzhou recruitment for Dian Wei.
- Fifth-scenario content exists with Dian Wei required in the Puyang raid and a new Lu Bu branch choice.
- Sixth-scenario content exists with Dingtao branch reactions to the Puyang choice.
- Seventh-scenario content exists with an emperor escort route, a protected envoy, a midroad ambush, and a destination victory condition.
- Eighth-scenario content exists with a Wan Castle escape route, protected Cao Ang, 007 branch reactions, and a destination victory condition.
- Ninth-scenario content exists with a Xiapi siege, flood-gate objective update, Lu Bu/Chen Gong pressure, and 008 branch reactions.
- Tenth-scenario content exists with White Horse relief, Guo Jia as a joined strategist, Yan Liang's vanguard, and 009 branch reactions.
- Eleventh-scenario content exists with Yan Ford pursuit, Wen Chou's countercharge, and 010 branch reactions.
- Twelfth-scenario content exists with Guandu's main assault, Yuan Shao's army, and 011 branch reactions.
- Thirteenth-scenario content exists with Wuchao depot raid, a burn-and-clear objective, and 012 branch reactions.
- Fourteenth-scenario content exists with Cangting pursuit, Zhang He recruitment, and 013 branch reactions.
- Fifteenth-scenario content exists with Ye outer defense, Zhang He as a deployable officer, and 014 branch reactions.
- Sixteenth-scenario content exists with Ye inner siege, Zhang He deployment, and 015 branch reactions.
- Seventeenth-scenario content exists with Ye surrender, last loyalist resistance, and 016 branch reactions.
## Milestone 5: Presentation
- Portrait pipeline.
- Unit sprites and animations.
- Battle effects.
- Music and sound.
- Chapter UI and visual novel scenes.
## Milestone 6: Content Expansion
- Recreate a long campaign structure chapter by chapter.
- Add historical officers, unique classes, and named equipment.
- Balance maps through repeated playtesting.