Add campaign chapter metadata

This commit is contained in:
2026-06-18 04:00:38 +09:00
parent b8378d9144
commit f0d941c273
8 changed files with 275 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr
## Current Slice ## Current Slice
- Sixty-seven campaign scenarios. - Sixty-seven campaign scenarios.
- Campaign chapters group the scenario order into story arcs.
- Grid movement. - Grid movement.
- Unit selection. - Unit selection.
- Move, attack, wait, end turn. - Move, attack, wait, end turn.
@@ -20,7 +21,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr
- Pre-battle Armory equipment changes save immediately and sync roster equipment plus inventory stock. - Pre-battle Armory equipment changes save immediately and sync roster equipment plus inventory stock.
- Pre-battle Roster can move optional officers between sortie and reserve within scenario limits. - Pre-battle Roster can move optional officers between sortie and reserve within scenario limits.
- Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells. - Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells.
- Pre-battle briefing shows the campaign battle number, location, and victory/defeat summary. - Pre-battle briefing shows the campaign chapter, chapter battle number, location, and victory/defeat summary.
- Escort scenarios can include required, non-controllable protected units. - Escort scenarios can include required, non-controllable protected units.
- Campaign saves joined officers, future deployments can require prior recruitment, and choices can make officers leave. - Campaign saves joined officers, future deployments can require prior recruitment, and choices can make officers leave.
- Completed saves reconcile officer join/leave rewards on load without replaying gold or items. - Completed saves reconcile officer join/leave rewards on load without replaying gold or items.
@@ -119,7 +120,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr
2. Open this folder as a Godot project. 2. Open this folder as a Godot project.
3. Run the project. The main scene is `res://scenes/battle_scene.tscn`. 3. Run the project. The main scene is `res://scenes/battle_scene.tscn`.
Campaign order lives at `res://data/campaign/campaign.json`. Scenarios live under `res://data/scenarios/`, with reusable officers, classes, items, skills, and terrain definitions under `res://data/defs/`. Placeholder BGM and SFX live under `res://audio/`. Campaign order and chapter ranges live at `res://data/campaign/campaign.json`. Scenarios live under `res://data/scenarios/`, with reusable officers, classes, items, skills, and terrain definitions under `res://data/defs/`. Placeholder BGM and SFX live under `res://audio/`.
Data files can be checked with `powershell -NoProfile -ExecutionPolicy Bypass -File tools\validate_data.ps1`. Data files can be checked with `powershell -NoProfile -ExecutionPolicy Bypass -File tools\validate_data.ps1`.

View File

@@ -3,6 +3,56 @@
"title": "Cao Cao Campaign", "title": "Cao Cao Campaign",
"start_scenario": "001_yellow_turbans", "start_scenario": "001_yellow_turbans",
"initial_joined_officers": ["cao_cao", "xiahou_dun"], "initial_joined_officers": ["cao_cao", "xiahou_dun"],
"chapters": [
{
"id": "rise_through_turmoil",
"title": "Rise Through Turmoil",
"start_scenario": "001_yellow_turbans",
"end_scenario": "009_xiapi_siege"
},
{
"id": "northern_unification",
"title": "Northern Unification",
"start_scenario": "010_white_horse_relief",
"end_scenario": "024_liaodong_pursuit"
},
{
"id": "southern_expedition",
"title": "Southern Expedition",
"start_scenario": "025_xinye_advance",
"end_scenario": "032_jiangling_rearguard"
},
{
"id": "western_frontier",
"title": "Western Frontier",
"start_scenario": "033_tong_pass_vanguard",
"end_scenario": "041_hanzhong_settlement"
},
{
"id": "ruxu_and_jingzhou",
"title": "Ruxu and Jingzhou",
"start_scenario": "042_ruxu_advance",
"end_scenario": "048_jing_province_settlement"
},
{
"id": "wei_succession",
"title": "Wei Succession",
"start_scenario": "049_luoyang_summons",
"end_scenario": "051_eastern_front_alarm"
},
{
"id": "jianye_campaign",
"title": "Jianye Campaign",
"start_scenario": "052_wu_river_line",
"end_scenario": "059_wu_surrender"
},
{
"id": "southern_settlement",
"title": "Southern Settlement",
"start_scenario": "060_southern_holdouts",
"end_scenario": "067_outer_islands"
}
],
"scenarios": [ "scenarios": [
{ {
"id": "001_yellow_turbans", "id": "001_yellow_turbans",

View File

@@ -16,7 +16,7 @@
- `scripts/core/battle_state.gd`: Battle rules, unit state, turns, AI, and victory checks. - `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. - `scripts/scenes/battle_scene.gd`: Rendering, input handling, and HUD wiring.
- `audio/bgm/*.wav` and `audio/sfx/*.wav`: Placeholder music loops and interface/action stingers. - `audio/bgm/*.wav` and `audio/sfx/*.wav`: Placeholder music loops and interface/action stingers.
- `data/campaign/campaign.json`: Campaign order and scenario paths. - `data/campaign/campaign.json`: Campaign chapter ranges, scenario order, and scenario paths.
- `data/defs/*.json`: Officers, classes, terrain, items, and skills. - `data/defs/*.json`: Officers, classes, terrain, items, and skills.
- `data/scenarios/*.json`: Scenario definitions. - `data/scenarios/*.json`: Scenario definitions.
@@ -27,7 +27,7 @@
- `data/defs/items.json`: Weapons, armor, accessories, and consumables. - `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/skills.json`: MP tactic definitions for damage, healing, target rules, and range.
- `data/defs/terrain.json`: Terrain colors, defense, avoid, and movement costs. - `data/defs/terrain.json`: Terrain colors, defense, avoid, and movement costs.
- `data/campaign/campaign.json`: Scenario sequence, titles, and resource paths. - `data/campaign/campaign.json`: Chapter ranges, scenario sequence, titles, and resource paths.
- `data/scenarios/*.json`: Dialogue, deployments, rewards, and branching flags. - `data/scenarios/*.json`: Dialogue, deployments, rewards, and branching flags.
## Battle Loop ## Battle Loop
@@ -35,7 +35,7 @@
1. Load battle data. 1. Load battle data.
2. Hydrate deployments through `DataCatalog`. 2. Hydrate deployments through `DataCatalog`.
3. Dispatch battle-start scenario events. 3. Dispatch battle-start scenario events.
4. Show scenario briefing with the campaign battle header, objective summary, and optional pre-battle shop, Armory, Roster, and Formation setup. 4. Show scenario briefing with the campaign chapter header, battle header, objective summary, and optional pre-battle shop, Armory, Roster, and Formation setup.
5. Player selects a unit. 5. Player selects a unit.
6. Briefing completion dispatches battle-begin events, including opening dialogue. 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 or support effect. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, low-HP warning rings, hover target preview badges, and floating combat text for core action and UI feedback. 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 or support effect. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, low-HP warning rings, hover target preview badges, and floating combat text for core action and UI feedback.
@@ -58,6 +58,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. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory 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. 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. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory 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 battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` campaign order and title data with `BattleState` briefing and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, `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 and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, `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. The `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing.

View File

@@ -82,7 +82,7 @@ data/scenarios/067_outer_islands.json
## Campaign ## Campaign
The campaign file defines scenario order, resource paths, and officers who are available at the start of a new campaign. The campaign file defines chapter ranges, scenario order, resource paths, and officers who are available at the start of a new campaign.
```json ```json
{ {
@@ -90,6 +90,14 @@ The campaign file defines scenario order, resource paths, and officers who are a
"title": "Cao Cao Campaign", "title": "Cao Cao Campaign",
"start_scenario": "001_yellow_turbans", "start_scenario": "001_yellow_turbans",
"initial_joined_officers": ["cao_cao", "xiahou_dun"], "initial_joined_officers": ["cao_cao", "xiahou_dun"],
"chapters": [
{
"id": "rise_through_turmoil",
"title": "Rise Through Turmoil",
"start_scenario": "001_yellow_turbans",
"end_scenario": "009_xiapi_siege"
}
],
"scenarios": [ "scenarios": [
{ "id": "001_yellow_turbans", "title": "Yingchuan Skirmish", "path": "res://data/scenarios/001_yellow_turbans.json" }, { "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": "002_sishui_gate", "title": "Sishui Gate Vanguard", "path": "res://data/scenarios/002_sishui_gate.json" },
@@ -162,6 +170,8 @@ The campaign file defines scenario order, resource paths, and officers who are a
} }
``` ```
`chapters` are display metadata for campaign presentation. Each chapter has a stable lowercase `id`, non-empty `title`, and inclusive `start_scenario`/`end_scenario` ids from the linear scenario order. When chapters are present, the validator requires the ranges to cover every campaign scenario exactly once, without overlaps or gaps. `CampaignState` uses them for briefing headers and chapter-local battle numbering; save files still store scenario ids, not chapter ids.
## Officers ## Officers
Officers hold identity, personal base stats, initial class, and starting equipment. Officers hold identity, personal base stats, initial class, and starting equipment.
@@ -479,4 +489,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 save file and resets `current_scenario_id` to `start_scenario`. 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. Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, 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

@@ -9,7 +9,7 @@
## Milestone 1: Playable Battle Prototype ## Milestone 1: Playable Battle Prototype
- Scenario briefing before battle with a campaign battle header and objective summary. - Scenario briefing before battle with a campaign chapter header, battle header, and objective summary.
- Select player units. - Select player units.
- Show move and attack ranges. - Show move and attack ranges.
- Show hover tile and unit information. - Show hover tile and unit information.
@@ -39,7 +39,7 @@
## Milestone 3: Scenario Layer ## Milestone 3: Scenario Layer
- Pre-battle briefing exists with a campaign battle header and objective summary. Full dialogue scenes are still planned. - Pre-battle briefing exists with a campaign chapter header, battle header, and objective summary. Full dialogue scenes are still planned.
- Scenario events exist for battle start, battle begin, turn start, and movement-triggered map tiles. - Scenario events exist for battle start, battle begin, turn start, and movement-triggered map tiles.
- 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.
- Opening battle dialogue exists through event actions. - Opening battle dialogue exists through event actions.
@@ -52,7 +52,7 @@
- Party roster persistence has a first save-file skeleton. - Party roster persistence has a first save-file skeleton.
- Level, experience, and equipment persistence. - Level, experience, and equipment persistence.
- Chapter selection and save/load. - Chapter selection and save/load.
- Linear sixty-seven-scenario campaign order exists. - Linear sixty-seven-scenario campaign order exists with chapter ranges for story arcs.
- Save reset exists. - Save reset exists.
- Victory rewards, consumable counts, equipment stock, and equipped gear persist. - Victory rewards, consumable counts, equipment stock, and equipped gear persist.
- A basic pre-battle shop with scenario-specific stock and 50% sell-back exists. Richer item management is still planned. - A basic pre-battle shop with scenario-specific stock and 50% sell-back exists. Richer item management is still planned.
@@ -134,7 +134,7 @@
- Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings. - Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings.
- Battle effects. First floating combat text now covers damage, recovery, misses, support effects, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items.
- Music and sound. Basic placeholder BGM and SFX now play for menus, battle flow, unit actions, tactics, items, and result states. - 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 has a first campaign battle header, and dialogue lines can choose left or right portrait placement; chapter-aware numbering remains planned once chapter data exists. - Chapter UI and visual novel scenes. Pre-battle briefing now uses campaign chapter ranges for chapter-aware numbering, and dialogue lines can choose left or right portrait placement.
## Milestone 6: Content Expansion ## Milestone 6: Content Expansion

View File

@@ -11,6 +11,10 @@ var start_scenario_id := ""
var scenario_order: Array[String] = [] var scenario_order: Array[String] = []
var scenario_paths := {} var scenario_paths := {}
var scenario_titles := {} var scenario_titles := {}
var chapter_order: Array[String] = []
var chapter_titles := {}
var chapter_scenario_ids := {}
var scenario_chapter_ids := {}
var initial_joined_officers: Array[String] = [] var initial_joined_officers: Array[String] = []
var current_scenario_id := "" var current_scenario_id := ""
var completed_scenarios: Array[String] = [] var completed_scenarios: Array[String] = []
@@ -35,6 +39,10 @@ func load_campaign(path: String) -> bool:
scenario_order.clear() scenario_order.clear()
scenario_paths.clear() scenario_paths.clear()
scenario_titles.clear() scenario_titles.clear()
chapter_order.clear()
chapter_titles.clear()
chapter_scenario_ids.clear()
scenario_chapter_ids.clear()
initial_joined_officers.clear() initial_joined_officers.clear()
for officer_id in parsed.get("initial_joined_officers", []): for officer_id in parsed.get("initial_joined_officers", []):
@@ -54,11 +62,51 @@ func load_campaign(path: String) -> bool:
scenario_paths[scenario_id] = scenario_path scenario_paths[scenario_id] = scenario_path
scenario_titles[scenario_id] = str(scenario.get("title", scenario_id)) scenario_titles[scenario_id] = str(scenario.get("title", scenario_id))
_load_chapters(parsed.get("chapters", []))
if start_scenario_id.is_empty() and not scenario_order.is_empty(): if start_scenario_id.is_empty() and not scenario_order.is_empty():
start_scenario_id = scenario_order[0] start_scenario_id = scenario_order[0]
return not scenario_order.is_empty() return not scenario_order.is_empty()
func _load_chapters(chapters) -> void:
if typeof(chapters) != TYPE_ARRAY:
push_error("Campaign chapters must be an array.")
return
for chapter in chapters:
if typeof(chapter) != TYPE_DICTIONARY:
push_error("Skipping malformed campaign chapter.")
continue
var chapter_id := str(chapter.get("id", ""))
var start_scenario := str(chapter.get("start_scenario", ""))
var end_scenario := str(chapter.get("end_scenario", ""))
var start_index := scenario_order.find(start_scenario)
var end_index := scenario_order.find(end_scenario)
if chapter_id.is_empty():
push_error("Skipping campaign chapter with empty id.")
continue
if chapter_order.has(chapter_id):
push_error("Skipping duplicate campaign chapter: %s." % chapter_id)
continue
if start_index == -1 or end_index == -1 or end_index < start_index:
push_error("Skipping campaign chapter with invalid scenario range: %s." % chapter_id)
continue
var scenario_ids: Array[String] = []
var overlaps_existing_chapter := false
for index in range(start_index, end_index + 1):
var scenario_id := scenario_order[index]
scenario_ids.append(scenario_id)
if scenario_chapter_ids.has(scenario_id):
push_error("Skipping campaign chapter %s because it overlaps scenario %s." % [chapter_id, scenario_id])
overlaps_existing_chapter = true
if overlaps_existing_chapter:
continue
for scenario_id in scenario_ids:
scenario_chapter_ids[scenario_id] = chapter_id
chapter_order.append(chapter_id)
chapter_titles[chapter_id] = str(chapter.get("title", chapter_id))
chapter_scenario_ids[chapter_id] = scenario_ids
func load_or_start(scenario_id: String) -> void: func load_or_start(scenario_id: String) -> void:
var loaded_save := load_game() var loaded_save := load_game()
if not loaded_save: if not loaded_save:
@@ -560,6 +608,48 @@ func get_scenario_count() -> int:
return scenario_order.size() return scenario_order.size()
func get_chapter_count() -> int:
return chapter_order.size()
func get_chapter_id_for_scenario(scenario_id: String) -> String:
return str(scenario_chapter_ids.get(scenario_id, ""))
func get_chapter_title_for_scenario(scenario_id: String) -> String:
var chapter_id := get_chapter_id_for_scenario(scenario_id)
if chapter_id.is_empty():
return ""
return str(chapter_titles.get(chapter_id, chapter_id))
func get_chapter_position_for_scenario(scenario_id: String) -> int:
var chapter_id := get_chapter_id_for_scenario(scenario_id)
var index := chapter_order.find(chapter_id)
if index == -1:
return 0
return index + 1
func get_scenario_position_in_chapter(scenario_id: String) -> int:
var chapter_id := get_chapter_id_for_scenario(scenario_id)
if chapter_id.is_empty() or not chapter_scenario_ids.has(chapter_id):
return 0
var scenario_ids: Array = chapter_scenario_ids[chapter_id]
var index := scenario_ids.find(scenario_id)
if index == -1:
return 0
return index + 1
func get_scenario_count_in_chapter(scenario_id: String) -> int:
var chapter_id := get_chapter_id_for_scenario(scenario_id)
if chapter_id.is_empty() or not chapter_scenario_ids.has(chapter_id):
return 0
var scenario_ids: Array = chapter_scenario_ids[chapter_id]
return scenario_ids.size()
func get_next_scenario_id(scenario_id: String) -> String: func get_next_scenario_id(scenario_id: String) -> String:
var index := scenario_order.find(scenario_id) var index := scenario_order.find(scenario_id)
if index == -1 or index + 1 >= scenario_order.size(): if index == -1 or index + 1 >= scenario_order.size():

View File

@@ -1841,6 +1841,13 @@ func _format_briefing_title(briefing: Dictionary) -> String:
var title := str(briefing.get("title", state.battle_name)) var title := str(briefing.get("title", state.battle_name))
if title.is_empty(): if title.is_empty():
title = campaign_state.get_scenario_title(active_scenario_id) title = campaign_state.get_scenario_title(active_scenario_id)
var chapter_title := campaign_state.get_chapter_title_for_scenario(active_scenario_id)
if not chapter_title.is_empty():
var chapter_position := campaign_state.get_chapter_position_for_scenario(active_scenario_id)
var chapter_count := campaign_state.get_chapter_count()
if chapter_position > 0 and chapter_count > 0:
return "Chapter %02d/%02d - %s" % [chapter_position, chapter_count, chapter_title]
return chapter_title
var position := campaign_state.get_scenario_position(active_scenario_id) var position := campaign_state.get_scenario_position(active_scenario_id)
var count := campaign_state.get_scenario_count() var count := campaign_state.get_scenario_count()
if position > 0 and count > 0: if position > 0 and count > 0:
@@ -1850,6 +1857,12 @@ func _format_briefing_title(briefing: Dictionary) -> String:
func _format_briefing_location(briefing: Dictionary) -> String: func _format_briefing_location(briefing: Dictionary) -> String:
var location := str(briefing.get("location", "")) var location := str(briefing.get("location", ""))
var chapter_title := campaign_state.get_chapter_title_for_scenario(active_scenario_id)
if not chapter_title.is_empty():
var battle_text := _format_briefing_chapter_battle_text(briefing)
if location.is_empty():
return battle_text
return "%s | %s" % [battle_text, location]
if campaign_state.campaign_title.is_empty(): if campaign_state.campaign_title.is_empty():
return location return location
if location.is_empty(): if location.is_empty():
@@ -1857,6 +1870,17 @@ func _format_briefing_location(briefing: Dictionary) -> String:
return "%s | %s" % [campaign_state.campaign_title, location] return "%s | %s" % [campaign_state.campaign_title, location]
func _format_briefing_chapter_battle_text(briefing: Dictionary) -> String:
var title := str(briefing.get("title", state.battle_name))
if title.is_empty():
title = campaign_state.get_scenario_title(active_scenario_id)
var position := campaign_state.get_scenario_position_in_chapter(active_scenario_id)
var count := campaign_state.get_scenario_count_in_chapter(active_scenario_id)
if position > 0 and count > 0:
return "Battle %02d/%02d: %s" % [position, count, title]
return title
func _format_briefing_objectives() -> String: func _format_briefing_objectives() -> String:
var text := "" var text := ""
var victory := str(state.objectives.get("victory", "")) var victory := str(state.objectives.get("victory", ""))

View File

@@ -120,6 +120,94 @@ foreach ($officerIdValue in (Get-Prop $campaign "initial_joined_officers" @()))
} }
} }
$campaignScenarioIds = @()
$campaignScenarioIdSet = New-Object System.Collections.Generic.HashSet[string]
foreach ($scenario in @($campaign.scenarios)) {
if ($scenario -is [System.Array] -or $null -eq $scenario) {
Fail "Campaign has a malformed scenario entry."
}
$scenarioId = [string](Get-Prop $scenario "id" "")
$scenarioTitle = [string](Get-Prop $scenario "title" "")
$scenarioPath = [string](Get-Prop $scenario "path" "")
if ([string]::IsNullOrWhiteSpace($scenarioId)) {
Fail "Campaign has a scenario with an empty id."
}
if (-not ($scenarioId -match '^[a-z0-9_]+$')) {
Fail "Campaign scenario has unstable id: $scenarioId"
}
if ([string]::IsNullOrWhiteSpace($scenarioTitle)) {
Fail "Campaign scenario $scenarioId has an empty title."
}
if ([string]::IsNullOrWhiteSpace($scenarioPath)) {
Fail "Campaign scenario $scenarioId has an empty path."
}
if (-not $campaignScenarioIdSet.Add($scenarioId)) {
Fail "Campaign has duplicate scenario id: $scenarioId"
}
$campaignScenarioIds += $scenarioId
}
if ($campaignScenarioIds.Count -le 0) {
Fail "Campaign has no scenarios."
}
if (-not $campaignScenarioIdSet.Contains([string]$campaign.start_scenario)) {
Fail "Campaign start_scenario references unknown scenario: $($campaign.start_scenario)"
}
if (Has-Prop $campaign "chapters") {
$chaptersValue = Get-Prop $campaign "chapters" $null
if ($chaptersValue -isnot [System.Array]) {
Fail "Campaign chapters must be an array."
}
if (@($chaptersValue).Count -le 0) {
Fail "Campaign chapters must not be empty."
}
$chapterIds = New-Object System.Collections.Generic.HashSet[string]
$coveredScenarioIds = New-Object System.Collections.Generic.HashSet[string]
foreach ($chapter in @($chaptersValue)) {
if ($chapter -is [System.Array] -or $null -eq $chapter) {
Fail "Campaign has a malformed chapter entry."
}
$chapterId = [string](Get-Prop $chapter "id" "")
$chapterTitle = [string](Get-Prop $chapter "title" "")
$startScenario = [string](Get-Prop $chapter "start_scenario" "")
$endScenario = [string](Get-Prop $chapter "end_scenario" "")
if ([string]::IsNullOrWhiteSpace($chapterId)) {
Fail "Campaign chapter has an empty id."
}
if (-not ($chapterId -match '^[a-z0-9_]+$')) {
Fail "Campaign chapter has unstable id: $chapterId"
}
if (-not $chapterIds.Add($chapterId)) {
Fail "Campaign has duplicate chapter id: $chapterId"
}
if ([string]::IsNullOrWhiteSpace($chapterTitle)) {
Fail "Campaign chapter $chapterId has an empty title."
}
if (-not $campaignScenarioIdSet.Contains($startScenario)) {
Fail "Campaign chapter $chapterId references unknown start_scenario: $startScenario"
}
if (-not $campaignScenarioIdSet.Contains($endScenario)) {
Fail "Campaign chapter $chapterId references unknown end_scenario: $endScenario"
}
$startIndex = [Array]::IndexOf($campaignScenarioIds, $startScenario)
$endIndex = [Array]::IndexOf($campaignScenarioIds, $endScenario)
if ($startIndex -lt 0 -or $endIndex -lt 0 -or $endIndex -lt $startIndex) {
Fail "Campaign chapter $chapterId has an invalid scenario range."
}
for ($index = $startIndex; $index -le $endIndex; $index++) {
$coveredScenarioId = [string]$campaignScenarioIds[$index]
if (-not $coveredScenarioIds.Add($coveredScenarioId)) {
Fail "Campaign chapter $chapterId overlaps scenario: $coveredScenarioId"
}
}
}
foreach ($scenarioId in $campaignScenarioIds) {
if (-not $coveredScenarioIds.Contains($scenarioId)) {
Fail "Campaign chapters do not cover scenario: $scenarioId"
}
}
}
function Check-Condition($Condition, [string]$ScenarioId, $EventIds, [int]$Width = -1, [int]$Height = -1, $Rows = $null, $KnownUnitIds = $null) { function Check-Condition($Condition, [string]$ScenarioId, $EventIds, [int]$Width = -1, [int]$Height = -1, $Rows = $null, $KnownUnitIds = $null) {
if ($Condition -is [System.Array]) { if ($Condition -is [System.Array]) {
foreach ($entry in $Condition) { foreach ($entry in $Condition) {