Files
dungeoncraft/docs/CAMPAIGN_EDITOR.md
T

993 lines
35 KiB
Markdown

# Campaign editor - goals, file format and branches
This document describes the campaign editor for DungeonCraft. The editor is opened from the title menu
and lets players create, save, open and share whole campaigns.
Status: design plus a first implementation. The data model (`CampaignDoc`, `MissionDoc`, `BranchDoc`,
`ZoneDoc`, `ScriptDoc`, `CampaignProgress`), the `.dccamp` file format, the title menu entry, the editor
page, starting a single mission, choosing the follow-up mission by branch goals and the basic Rhai script
runtime (`on_start`, `m.log`, `m.objective`, `m.spawn_at`, `m.spawn_count_in_zone`, `m.spawn_wave`) are
implemented. Map parameters beyond the basic ones, a graphical zone editor, a real script editor, further
script hooks, persistent progress and carry-over between custom missions are still planned. See
[16a. Implemented state](#16a-implemented-state) for the exact list.
Throughout this document, **(planned)** marks features that are designed but not implemented yet.
## 1. Goals
The campaign editor is meant to support:
- Starting campaigns from the title menu. *(implemented: single missions and their follow-ups)*
- Creating new campaigns. *(implemented)*
- Opening existing campaigns. *(implemented)*
- Saving campaigns as files and sharing them. *(implemented)*
- Editing the missions of a campaign. *(partial: title, goals, next mission, branches, script)*
- Defining mission goals. *(implemented, as text)*
- Creating campaign branches. *(implemented, as text)*
- Editing briefing, title, story, hints and objectives. *(partial: only the title; story only via JSON)*
- Editing map parameters. **(planned)** - size, seed, gold, portals, treasures, waves and time limit
exist in the file format but can only be changed by editing the JSON.
- Placing fortresses, gates, rivers, caves, mana springs and rivals. **(planned)**
- Drawing trigger zones graphically. **(planned)** - zones can only be written in the JSON so far.
- Editing mission scripts in Rhai. *(partial: single-line prompt)*
- A script editor with syntax highlighting and syntax checking. *(partial: syntax checking only)*
- Test-playing missions. *(implemented)*
- Saving campaign progress. *(partial: kept in memory for the current session only)*
## 2. Starting from the title menu
The editor is an entry of the title menu. The current title menu (`Menu::draw_title` in `src/menu.rs`)
shows these entries in this order:
```text
DungeonCraft
- Continue (only if an autosave exists)
- Campaign
- Campaign Editor
- Multiplayer
- New Game
- Load Game
- Settings
- Account: <name> (only when logged in to a server)
- Download Standalone
- Bestiary
- Help
```
Menu wiring as implemented:
```rust
Btn::CampaignEditor // title menu button
Page::CampaignEditor // the editor page; Back returns to Page::Title
```
There is no separate `MenuAction::OpenCampaignEditor`: the button switches the page directly inside
`Menu::click`. The editor actions are `MenuAction::CampaignNew`, `CampaignOpen`, `CampaignSave`,
`CampaignAdd`, `CampaignDelete(usize)`, `CampaignEditName`, `CampaignEditTitle`, `CampaignEditGoals`,
`CampaignEditNext`, `CampaignEditBranches`, `CampaignEditScript` and `CampaignStart(usize)`; they are
handled in `src/main.rs`.
**(planned)** The editor should also be reachable from the pause menu while a campaign is played, without
destroying the current mission unless a backup has been made. The pause menu has no editor entry yet.
## 3. Saving and opening campaign files
Campaign files can be passed on to other players. File extension (`CAMPAIGN_EXTENSION`):
```text
.dccamp
```
A `.dccamp` file is JSON (pretty-printed by `CampaignDoc::save()`). It contains the complete campaign:
- campaign name and author
- missions
- mission goals
- branches
- map parameters (currently: size, seed, gold, portals, treasures, waves, wave limit, first wave, time limit)
- scripts
- trigger zones
- optional language texts **(planned)**
- version and format identifiers
Example export:
```json
{
"format": "dungeoncraft.campaign",
"version": 1,
"name": "My own campaign",
"author": "Player",
"missions": [
{
"key": "my01",
"title": "The first shaft",
"story": "A small outpost, one shard, two paths.",
"size": [96, 80],
"seed": 101010,
"gold": 2500,
"goals": [
"Shards"
],
"next": "my02a",
"branches": [
{
"id": "killall_path",
"goal": "KillAll",
"title": "Destroy the guardhouse",
"next": "my02a"
},
{
"id": "loot_path",
"goal": "Loot(3000)",
"title": "Plunder the vaults",
"next": "my02b"
}
],
"zones": [
{
"name": "north_gate",
"shape": "circle",
"at": [67.0, 40.0],
"radius": 3.0
}
],
"script": {
"language": "rhai",
"source": "fn on_start(m) { m.log('Crookfinger: There are two paths.'); }"
}
}
]
}
```
Note: zone coordinates (`at`, `from`, `to`, `tiles`) are **map tile coordinates**, not fractions of the
map size. A circle zone without `at` is centred on the player's core.
All fields have defaults (see section 10), so a minimal mission only needs `key` and `title`. A file is
only accepted when `CampaignDoc::validate()` reports no errors.
Editor file functions:
```text
Open ... - implemented (Btn::CampaignOpen)
Save - implemented (Btn::CampaignSave), downloads/writes <name>.dccamp
Start - implemented (Btn::CampaignStart), test-plays the selected mission
```
How this works per platform:
- Browser, save: the file is downloaded as `<name>.dccamp` (`platform::download`). The file name is
built by `CampaignDoc::file_name()` from the ASCII letters, digits, `_` and `-` of the campaign name,
falling back to `campaign.dccamp`.
- Browser, open: *Open* shows the browser file picker; the chosen file is read as JSON.
- Native build, save: the file is written into the current working directory.
- Native build, open: the file picker is not available; after pressing *Open*, drop the `.dccamp` file
onto the window. (A file dropped without pressing *Open* first is treated as a savegame.)
## 4. Campaign structure
A campaign is no longer just a linear list of missions. It is a mission graph.
Simplified:
```text
my01
|
|--> goal A reached: my02a
|--> goal B reached: my02b
```
More complex:
```text
my01
|
|--> KillAll: my02a
|--> Loot(3000): my02b
my02a
|
|--> Shards: my03a
|--> Rivals: my03b
my02b
|
|--> Freed(1): my03c
|--> Towers: my03b
```
This allows a campaign to have several sections, alternative paths and paths that join again.
## 5. Missions with branches
A mission definition distinguishes between normal goals and branch goals.
Meaning:
- `goals`: the mission's main goals. The mission is won when **all** of them are reached.
- `branches`: alternative mission endings; each branch has a goal and a follow-up mission.
- `next`: the follow-up mission when no branch applies.
Example:
```json
{
"key": "my01",
"title": "The two paths",
"story": "The knights have either a guardhouse or a treasury. You decide which one you strike first.",
"goals": [
"Shards"
],
"branches": [
{
"id": "kill_path",
"goal": "KillAll",
"title": "Destroy the guardhouse",
"next": "my02a",
"priority": 2
},
{
"id": "loot_path",
"goal": "Loot(3000)",
"title": "Plunder the vaults",
"next": "my02b",
"priority": 1
}
]
}
```
Current rules (`CampaignDoc::next_mission_choice` in `src/campaign_doc.rs`):
1. The mission ends with victory when all of its `goals` are reached. Branch goals do **not** end the
mission by themselves. A mission with an empty `goals` list cannot be won.
2. At the moment of victory, every branch whose goal is already reached is a candidate.
3. Among the candidates, the branch with the **highest** `priority` wins. A missing `priority` counts as
`0`. With equal priority, the branch defined first wins.
4. The winning branch's `next` is the follow-up mission, and its `id` is recorded as `completed_choice`.
5. If no branch goal is reached, the mission's `next` is used. If `next` is not set either and the mission
has no branches, the next mission in the list follows. Otherwise the campaign run ends and the player
returns to the editor.
In the example above, if both `KillAll` and `Loot(3000)` are reached when the shards are taken,
`kill_path` (priority 2) wins and `my02a` follows.
**(planned)** A mission with branches ends as soon as one branch goal is reached (without requiring the
main goals), and it is not considered complete until a branch is reached.
## 6. Missions with alternative goals a and b
**(planned)** Example with two alternative main goals. The `goal_mode` field does not exist yet; today the
same effect needs a main goal that both paths share.
```json
{
"key": "my03",
"title": "The neighbours' quarrel",
"story": "Mossgrim or the Order - you can only take out one of them first.",
"goal_mode": "any",
"branches": [
{
"id": "mossgrim_first",
"goal": "Rivals",
"title": "Take out Mossgrim first",
"next": "my04a"
},
{
"id": "order_first",
"goal": "Heart",
"title": "Break the Order's gate first",
"next": "my04b"
}
]
}
```
Planned player experience:
- The player sees both paths in the briefing.
- The interface shows two objectives:
- Destroy Mossgrim's core.
- Break the Order's gate.
- As soon as one of them is reached, the mission ends.
- The progress stores which path was chosen.
- The next mission depends on that path.
## 7. Mission progress with branches
Implemented progress (`CampaignProgress`, kept in memory for the current test run, not saved to disk or
browser storage). The lists are indexed by mission position in the campaign:
```json
{
"done": [true, false, false],
"completed_choice": ["loot_path", null, null]
}
```
A new `CampaignProgress` is created each time a mission is started with *Start* in the editor.
**(planned)** Persistent progress format:
```json
{
"format": "dungeoncraft.progress",
"campaign": "My own campaign",
"done": {
"my01": true,
"my02b": false
},
"completed_choice": {
"my01": "loot_path"
},
"unlocked": [
"my01",
"my02b"
],
"carry": {
"my01": {
"creatures": [["Ogre", 3, 120]],
"gold": 500,
"rune_free": 0,
"rune_final": 0
}
}
}
```
Rules:
- A mission is only complete when it is won (currently: all `goals` reached). *(implemented)*
- The chosen branch is stored as `completed_choice`. *(implemented)*
- The next mission is determined by `next` of the chosen branch. *(implemented)*
- Other branches of an already decided mission count as skipped. **(planned)**
- Carry-over goes from the decided mission to the next mission. **(planned)** - custom missions currently
start without carry-over (`start_mission_doc(def, i, None)`).
- A mission where paths join again can be entered from several earlier missions. In that case the editor
has to decide which carry state is used, or each path stores its own carry. **(planned)**
**(planned)** Variant with separate carry per branch:
```json
"completed_choice": {
"my01": "loot_path"
},
"branch_done": {
"my01.kill_path": false,
"my01.loot_path": true
},
"branch_carry": {
"my01.loot_path": {
"gold": 900,
"creatures": [["Ogre", 3, 0]]
}
}
```
## 8. Editor view for campaign branches
Implemented editor page (`Menu::draw_campaign_editor`):
- Header with the campaign name (click to rename) and the author, if set.
- Field buttons for the selected mission: `T` (title), `G` (goals), `N` (next mission), `B` (branches),
`S` (script).
- Scrollable mission list; each row shows `<n>. <title>` and a detail line: number of branches, or the
next mission, or the goals, plus the number of zones and scripts when present.
- Footer with the mission count, the file name and the validation errors (in red).
- Action buttons: *New*, *Add*, *Start*, *Del* (asks for confirmation by a second click), *Open*, *Save*,
*Back*.
**(planned)** Campaign overview as a tree:
```text
My01 The two paths
|- KillAll -> My02a The guardhouse
| |- Shards -> My03a
| `- Rivals -> My03b
`- Loot(3000) -> My02b The treasury
|- Freed(1) -> My03c
`- Towers -> My03b
```
Editor functions:
- Add a mission. *(implemented)*
- Remove a mission. *(implemented)*
- Rename a mission. *(implemented: title; the key is generated as `m01`, `m02`, ...)*
- Move a mission. **(planned)**
- Add a branch goal. *(implemented, via the branch text)*
- Choose the `goal`. *(implemented, as text)*
- Choose the `next` mission. *(implemented, as text)*
- Edit the branch title. *(implemented, via the branch text)*; branch description **(planned)**.
- Set the priority. *(implemented, via the branch text)*
- Start a test path. *(implemented: Start plays the selected mission and its follow-ups)*
- Cycle check: campaigns must not contain endless loops without progress. **(planned)**
- Reachability check: all missions should be reachable from the start, unless they are explicitly marked
as optional endings or extra missions. **(planned)**
Branch text format used by the `B` prompt (`parse_branches` / `branches_to_text`): branches are separated
by `;`, the fields of a branch by `:`:
```text
id:goal:next[:priority[:title]]
kill_path:KillAll:my02a:2:Destroy the guardhouse; loot_path:Loot(3000):my02b:1:Plunder the vaults
```
Goals in the `G` prompt are separated by commas, for example `Shards, Loot(2000)`.
## 9. Saving a campaign file with branches
A saved campaign contains its branches.
Example mission with two follow-up missions:
```json
{
"key": "my01",
"title": "The two paths",
"size": [96, 80],
"seed": 101010,
"gold": 2500,
"goals": [
"Shards"
],
"branches": [
{
"id": "kill_path",
"goal": "KillAll",
"title": "Take out the guard first",
"next": "my02a",
"priority": 2
},
{
"id": "loot_path",
"goal": "Loot(3000)",
"title": "Plunder the vaults first",
"next": "my02b",
"priority": 1
}
],
"script": {
"language": "rhai",
"source": "fn on_start(m) { m.objective('Choose a path: the guardhouse or the vaults.'); }"
}
}
```
The editor checks:
- `my02a` exists. *(implemented)*
- `my02b` exists. *(implemented)*
- `goal KillAll` makes sense for this mission. **(planned)** - only the goal syntax is checked.
- `goal Loot(3000)` makes sense for this mission. **(planned)**
- There is at least one start path. **(planned)**
- The mission really ends when one of the branch goals is reached. **(planned)**
Validation runs when the editor page is drawn (errors are shown in the footer), when a file is opened and
before *Start*. *Save* does not block on validation errors.
## 10. Rust data structure
The editor works with data-driven missions. The structures in `src/campaign_doc.rs` (all derive
`Clone, Debug, Serialize, Deserialize`; every field is `#[serde(default)]`, so missing JSON fields are
allowed):
```rust
pub struct CampaignDoc {
pub format: String, // default "dungeoncraft.campaign"
pub version: u32, // default 1
pub name: String, // CampaignDoc::default() uses "Neue Kampagne"
pub author: String,
pub missions: Vec<MissionDoc>,
}
pub struct MissionDoc {
pub key: String,
pub title: String,
pub story: String,
pub size: (usize, usize), // default (96, 80)
pub seed: u32, // default 0
pub gold: f32, // default 3000.0
pub portals: usize, // default 1
pub treasures: usize, // default 0
pub goals: Vec<String>,
pub waves: bool, // default false
pub wave_limit: u32, // default 0 (no limit)
pub first_wave: f32, // default 300.0 seconds
pub time_limit: f32, // default 0.0 (no limit)
pub next: Option<String>,
pub branches: Vec<BranchDoc>,
pub zones: Vec<ZoneDoc>,
pub script: Option<ScriptDoc>,
}
pub struct BranchDoc {
pub id: String,
pub goal: String,
pub title: String,
pub next: String,
pub priority: Option<u32>, // higher wins; None counts as 0
}
pub struct ZoneDoc {
pub name: String,
pub shape: String, // default "circle"
pub at: Option<(f32, f32)>, // tile coordinates; default: the player's core
pub radius: Option<f32>, // default 2.0
pub tiles: Option<Vec<(i32, i32)>>,
pub from: Option<(f32, f32)>,
pub to: Option<(f32, f32)>,
pub width: Option<f32>, // line width, default 1.0
}
pub struct ScriptDoc {
pub language: String, // default "rhai"
pub source: String,
}
pub struct CampaignProgress { // in memory only
pub done: Vec<bool>, // per mission index
pub completed_choice: Vec<Option<String>>, // branch id per mission index
}
```
Zone shapes understood by the runtime (spawn positions and debug overlay): `circle` (`at`, `radius`;
also used for any unknown shape name), `rect` / `rectangle` / `square` (`from`, `to`), `line` / `path`
(`from`, `to`, `width`) and `tiles` (`tiles`). `polygon` is **(planned)**.
How a `MissionDoc` becomes a playable mission (`campaign::mission_def_from_doc`): `key`, `size`, `seed`,
`goals`, `gold`, `portals`, `treasures`, `waves`, `wave_limit`, `first_wave` and `time_limit` are copied;
`waves: true` also enables the Order's gate and its defenders. Fortresses, rivers, islands, caves, rivals
and starting creatures stay empty. `title`, `story`, `next`, `branches`, `zones` and `script` are used by
the editor and by `src/main.rs`, not by the map generator.
The `String` goals are translated into real `Goal` values by `campaign_doc::parse_goal`. Unknown goals are
rejected by validation.
```text
"Shards" -> Goal::Shards
"Heart" -> Goal::Heart
"KillAll" -> Goal::KillAll
"Loot(3000)" -> Goal::Loot(3000)
"Survive(4)" -> Goal::Survive(4)
"Springs(2)" -> Goal::Springs(2)
"Freed(1)" -> Goal::Freed(1)
"Rivals" -> Goal::Rivals
"Towers" -> Goal::Towers
```
## 11. Branch logic in the game
Implemented flow (`src/main.rs`, main loop and `UiAction::EndNext`):
```text
Mission runs.
When all main goals are reached: the game reports victory (Game state 1).
On victory, once:
next_mission_choice() picks the branch (or next / the following mission).
CampaignProgress.mark_done(index, choice) stores done and completed_choice.
The follow-up mission index is remembered.
The end screen's "next" button starts the follow-up mission (with its on_start script),
or returns to the editor when there is none.
After a defeat, the same button restarts the mission.
```
**(planned)** Flow where a branch goal ends the mission directly:
```text
Mission runs.
When a normal goal is reached: show progress.
When a branch goal is reached:
End the mission immediately.
Store completed_choice.
Create the carry-over from the current game state.
Unlock the next mission.
Show a briefing or a success message.
```
Pseudo logic:
```rust
if mission_has_branches {
for branch in mission.branches {
if goal_is_done(goal) && mission.state != Victory {
mission.state = Victory;
mission.completed_choice = branch.id;
progress.set_done(mission.key, branch.id, carry_from_game());
progress.unlock_mission(branch.next);
break;
}
}
} else if all_mission_goals_done() {
mission.state = Victory;
}
```
When several branches are reached at the same time:
- Sort the branches by `priority`, highest first. *(implemented)*
- Without `priority` (treated as 0), use the order in the JSON. *(implemented)*
- If the player should choose, the editor can enable `ask_on_conflict: true`. **(planned)**
## 12. Example mission in the campaign editor
**(planned)** Example of a mission with graphical zones, a script and branches. The JSON fields are valid
today, but the script uses hooks and functions that do not exist yet (`on_unit_reaches`,
`m.load_editor_zones`, `m.has_flag`, `m.set_flag`, `m.warn`, `m.destroy_walls_in_zone`,
`m.destroy_doors_in_zone`); see section 14.
```json
{
"key": "my05",
"title": "The north wall or the vault",
"story": "To the north is a thin wall. Behind it waits a squad of knights. To the east lies a vault. You have to decide.",
"size": [104, 96],
"seed": 123456,
"gold": 3000,
"goals": [
"Shards"
],
"branches": [
{
"id": "north_wall",
"goal": "KillAll",
"title": "Blast the north wall",
"next": "my06a",
"priority": 2
},
{
"id": "east_vault",
"goal": "Loot(4000)",
"title": "Empty the vault",
"next": "my06b",
"priority": 1
}
],
"zones": [
{
"name": "north_gate",
"shape": "circle",
"at": [73.0, 48.0],
"radius": 3.0
},
{
"name": "east_vault",
"shape": "tiles",
"tiles": [[74, 44], [74, 45], [75, 44], [75, 45]]
}
],
"script": {
"language": "rhai",
"source": "fn on_start(m) { m.load_editor_zones(); m.objective('Choose: the north wall or the vault.'); }\n\nfn on_unit_reaches(m, unit, zone) {\n if zone == 'north_gate' && unit.side == 0 && !m.has_flag('north_opened') {\n m.set_flag('north_opened');\n m.warn('The north wall falls!');\n m.destroy_walls_in_zone('north_gate');\n m.spawn_count_in_zone('north_gate', 'Knight', 3, 1, 2);\n }\n\n if zone == 'east_vault' && unit.side == 0 && !m.has_flag('vault_alarm') {\n m.set_flag('vault_alarm');\n m.warn('Vault alarm!');\n m.destroy_doors_in_zone('east_vault');\n m.spawn_count_in_zone('east_vault', 'Knight', 2, 1, 3);\n }\n}"
}
}
```
## 13. Editor error checks
The editor shows errors before a campaign is played. Implemented checks (`CampaignDoc::validate`):
- The campaign has no missions.
- A mission `key` is empty, invalid or duplicated (keys: 1-48 ASCII letters, digits, `_` or `-`).
- `title` is missing.
- `size` is too small or too large (each side must be 32 to 512 tiles).
- A goal name is unknown (main goals and branch goals).
- `next` points to a mission that does not exist or is not a valid key.
- A branch `id` is not a valid key.
- A branch `next` points to a mission that does not exist or is not a valid key.
Script checks run when a script is entered with the `S` button (see section 14):
- The script source cannot be parsed or run as Rhai.
- The script spawns an unknown creature kind.
- The script spawns into an unknown zone name.
**(planned)** further checks:
- `story` is missing.
- `seed` is missing.
- `gold` is not allowed.
- There is no reachable start mission.
- A mission is not reachable from any other mission.
- Branches create a cycle without an ending.
- `KillAll` is chosen, but there are no knights.
- `Heart` is chosen, but the mission has no gate.
- `Shards` is chosen, but the mission has no shard.
- `Loot(3000)` is chosen, but there is not enough vault gold.
- `Springs(2)` is chosen, but there are fewer than two mana springs.
- `Freed(1)` is chosen, but there is no chained creature.
- The script uses unknown functions (today they only fail when the script runs).
- A zone has an invalid shape or invalid coordinates.
- Scripts in files that were opened or edited as JSON are not re-validated on load.
## 14. Script editor with syntax highlighting
Implemented (simple): the `S` button opens a text prompt with the current Rhai source of the selected
mission.
- The prompt is a single line: control characters (including line breaks) are removed and the source is
limited to 600 characters. Longer scripts must be written in the `.dccamp` JSON.
- An empty input removes the script.
- Before storing, the source is checked with `script::run_start()` and `validate_script_source()`:
syntax errors, unknown creature names and unknown zone names are rejected and shown in the status line.
- The source is stored as `script.source` with `"language": "rhai"`.
- Text prompts are only available in the browser build; in the native build the field buttons do nothing.
Runtime: when a custom mission starts, `on_start(m)` runs once (`start_custom_script` in `src/main.rs`).
Messages, the objective, spawns and an immediate wave are applied to the game. A script with a language
other than `rhai` is skipped with a message; runtime errors are shown as `msg.script_error`. Scripts are
limited to 200,000 Rhai operations.
Implemented script API:
```text
m.log(text) - message in the log
m.objective(text) - shows the objective as a message
m.spawn_at(side, kind, x, y, count, level) - spawn at tile coordinates
m.spawn_count_in_zone(zone, kind, count, side, level) - spawn inside a named zone
m.spawn_wave(count) - start the hero waves now
```
`side` is a number (`0` to `4`) or a name (`"PLAYER"`, `"HEROES"`, `"MOSSGRIM"`, `"NIGHTWELL"`,
`"RIVAL3"`; case-insensitive, `"HERO"` and `"RIVAL"` also work). `count` is clamped to 1-10 (waves: 1-20),
`level` to 1-10. Creature kinds (case-insensitive): Worker, Kobold, Spider, Mage, Ogre, Knight, Archer,
Priest, Dwarf, Paladin, Jailer, Priestess, Chainbreaker, Bat, Gargoyle, Owl, Wyrm, Cogling, Revenant,
Giant, Fairy, BattleMage. Rhai reserves `spawn`, so direct spawns use `spawn_at`.
**(planned)** editor features:
- Text area inside the game editor (multi-line).
- Line numbers.
- Syntax highlighting.
- Keyboard input on desktop.
- Touch: a large text field or an editor helper dialog.
- Starting a test mission directly from the script editor.
- Error display for unknown mission functions.
- Mission API help.
Planned colours:
```text
Keyword: light yellow
Comment: grey
String: green
Number: orange
Mission method: light blue
Creature kind: cyan
Zone name: violet
Error: red
```
**(planned)** Example using planned hooks and functions (`on_unit_reaches`, `m.load_editor_zones`,
`m.has_flag`, `m.set_flag`, `m.warn`, `m.destroy_walls_in_zone`). Only `on_start`, `m.objective` and
`m.spawn_count_in_zone` exist today:
```rhai
const HEROES = 1;
const PLAYER = 0;
fn on_start(m) {
m.load_editor_zones();
m.objective("Two paths: north wall or vault.");
}
fn on_unit_reaches(m, unit, zone) {
if zone != "north_gate" {
return;
}
if unit.side != PLAYER {
return;
}
if m.has_flag("north_opened") {
return;
}
m.set_flag("north_opened");
m.warn("The north wall falls!");
m.destroy_walls_in_zone("north_gate");
m.spawn_count_in_zone("north_gate", "Knight", 3, HEROES, 2);
}
```
A script that works today:
```rhai
fn on_start(m) {
m.log("Crookfinger: The knights are coming.");
m.objective("Hold the north gate.");
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
m.spawn_at("PLAYER", "Ogre", 20.5, 20.5, 1, 2);
m.spawn_wave(1);
}
```
## 15. Sharing campaigns
A player can pass a campaign on as a file:
```text
my_campaign.dccamp
```
Another player opens it:
```text
Title menu -> Campaign Editor -> Open -> my_campaign.dccamp
```
After that the campaign can be played (*Start* on the first mission).
The file contains no savegames. It only contains mission definitions and scripts; optional starting
progress data is **(planned)**. Savegames stay separate.
File types:
```text
.dccamp - campaign definition
.dcsave - savegame
```
**(planned)** A savegame can later reference an active campaign, while the campaign file stays editable
and shareable. Today a savegame of a custom mission does not remember the campaign.
## 16. Implementation order
The implementation proceeds in stages.
### Stage A: data model - done
- `CampaignDoc` *(done)*
- `MissionDoc` *(done)*
- `BranchDoc` *(done)*
- `ZoneDoc` *(done)*
- `ScriptDoc` *(done)*
- JSON loading and saving *(done)*
- Tests for example campaigns *(done: unit tests in `src/campaign_doc.rs` and `src/campaign.rs`)*
### Stage B: campaign editor in the menu - done
- Title menu entry `Campaign Editor`. *(done)*
- Editor page with the mission list. *(done)*; a list of several campaigns **(planned)**.
- Create a new campaign. *(done)*
- Open a campaign. *(done)*
- Save a campaign. *(done)*
### Stage C: mission editor - partial
- Mission list within a campaign. *(done)*
- Add, remove, select missions. *(done)*
- Edit mission data. *(partial: title only; story and key **(planned)**)*
- Edit branches. *(done, as text)*
- Edit goals. *(done, as text)*
- Edit map parameters. **(planned)**
### Stage D: playing branches - partial
- The mission ends on branch success. **(planned)** - today it ends when all main goals are reached and the
branch is chosen at that moment.
- The follow-up mission is chosen and can be started. *(done)*
- Progress stores `completed_choice`. *(done, in memory only)*
- Carry-over follows the chosen path. **(planned)**
- The campaign overview shows branched progress. **(planned)**
### Stage E: zone editor - planned
- Draw a circle. **(planned)**
- Draw a rectangle. **(planned)**
- Draw a polygon. **(planned)**
- Mark tiles. **(planned)**
- Place lines. **(planned)**
- Take zones over into the script automatically. **(planned)**
Zones written in the JSON (circle, rectangle, line, tiles) already work for spawning and the debug overlay.
### Stage F: script editor - partial
- Text editor with syntax highlighting. **(planned)** - a single-line prompt exists.
- Rhai syntax check. *(done)*
- Mission API help. **(planned)**
- Start a test mission. *(done, via Start in the editor)*
### Stage G: campaign tests - partial
- Build an example campaign. **(planned)** - no example `.dccamp` ships with the game.
- Build a branch campaign. *(done in unit tests only)*
- Export `.dccamp`. *(done)*
- Import `.dccamp`. *(done)*
- Save and load progress. **(planned)**
## 16a. Implemented state
Data model and file format (`src/campaign_doc.rs`):
- `CampaignDoc`, `MissionDoc`, `BranchDoc`, `ZoneDoc`, `ScriptDoc` and `CampaignProgress` as listed in
section 10, with serde defaults for every field.
- `CampaignDoc::load()` / `save()` read and write `.dccamp` JSON; `file_name()` builds the file name;
`add_mission()` appends a mission with key `mNN`, title `Mission N`, seed `N * 101` and goal `Shards`.
- `CampaignDoc::validate()` performs the checks listed in section 13.
- `parse_goal()` / `parse_goals()` turn goal strings into `Goal` values; `parse_list()`,
`parse_branches()` and `branches_to_text()` convert the prompt text formats.
- `CampaignDoc::next_mission_choice()` chooses the follow-up mission (section 5).
Menu and editor page (`src/menu.rs`, actions handled in `src/main.rs`):
- Title menu entry `Btn::CampaignEditor` opens `Page::CampaignEditor`.
- Editor page: campaign name (click to edit), author, mission list with details, validation errors,
file name, field buttons `T`/`G`/`N`/`B`/`S`, and the buttons *New*, *Add*, *Start*, *Del*
(with confirmation), *Open*, *Save*, *Back*.
- Field editing uses browser text prompts: campaign name, mission title, goals (comma separated), next
mission key, branches (`id:goal:next:priority:title`, separated by `;`) and the Rhai script source.
- *Open* uses the browser file picker (native: drop the file onto the window after pressing *Open*);
only files that pass validation are loaded. *Save* downloads the `.dccamp` file.
Playing custom missions (`src/campaign.rs`, `src/main.rs`):
- `campaign::mission_def_from_doc()` and `campaign::start_mission_doc()` build a playable mission from a
`MissionDoc` with the same generator as the built-in campaign.
- *Start* validates the campaign and plays the selected mission; after a victory the end screen starts the
follow-up chosen by `next_mission_choice()`; after a defeat it restarts the mission. Without a follow-up
the player returns to the editor.
- `CampaignProgress` records `done` and `completed_choice` for the current run (in memory only).
Scripting (`src/script.rs`, `src/main.rs`):
- `on_start(m)` runs in Rhai when a custom mission starts. Supported: `m.log(text)`, `m.objective(text)`,
`m.spawn_at(side, kind, x, y, count, level)`, `m.spawn_count_in_zone(zone, kind, count, side, level)` and
`m.spawn_wave(count)`. Sides can be numbers (`0` to `4`) or names (`PLAYER`, `HEROES`, `MOSSGRIM`,
`NIGHTWELL`, `RIVAL3`). Messages, the objective, spawns and an immediate hero wave are applied to `Game`.
- `Btn::CampaignEditScript` stores a Rhai source in `MissionDoc.script`; the source is checked with
`script::run_start()` and `validate_script_source()` before it is stored. Syntax errors, unknown creature
names and unknown zone names are rejected.
Debug overlays (Settings, *Debug* tab):
- Separate options for FPS, frame time, ping, trigger zones, debug command markers and path lines.
- *Show trigger zones* draws the zones of the running custom mission on the ground (dimmed once the
mission is done); colours are derived from the zone name.
- *Show path lines* draws the next path tiles of up to 120 of the player's own creatures.
- The `guard`, `attack` and `flag`/`gather` command markers are always shown; all other command markers
only with *Show debug command markers*.
Known limitations of the current implementation:
- Custom missions have no fortresses, so a `Shards` goal counts as reached immediately (there are zero
shards to take). The same applies to `Heart` when `waves` is off (there is no gate), and usually to `Towers` (no knight towers are placed).
The default mission created by *New* / *Add* uses `Shards`, so it is won right after the start.
- Tool unlocks and research use the built-in campaign's schedule for the mission's position in the list.
- No carry-over between custom missions; progress is not persisted.
- `story` is stored but not shown in a briefing yet.
Still open (planned): graphical zone editor, a multi-line Rhai editor with syntax highlighting, further
mission hooks (`on_unit_reaches`, `on_unit_crosses`, flags, zone actions, timed hooks), full map
parameters (fortresses, rivers, caves, springs, rivals), branch goals that end the mission directly,
carry-over of creatures and gold through branches, persistent progress, a briefing with story, and a
campaign overview tree.
## 17. Summary
A campaign editor for whole campaigns needs more than mission texts; it needs a mission graph.
Core idea:
```text
Mission my01
-> goal A reached: mission my02a
-> goal B reached: mission my02b
```
The campaign is saved as `.dccamp`:
```json
"branches": [
{ "goal": "KillAll", "next": "my02a" },
{ "goal": "Loot(3000)", "next": "my02b" }
]
```
The editor must be able to show, check and save these branches. The player then sees alternative
objectives, and the campaign leads to different follow-up missions depending on the goals reached.