Files
dungeoncraft/docs/CAMPAIGN_EDITOR.md
T

35 KiB

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 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:

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:

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):

.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:

{
  "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:

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:

my01
  |
  |--> goal A reached: my02a
  |--> goal B reached: my02b

More complex:

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:

{
  "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.

{
  "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:

{
  "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:

{
  "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:

"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:

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 ::

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:

{
  "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):

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.

"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):

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:

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:

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.

{
  "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:

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:

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:

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:

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:

my_campaign.dccamp

Another player opens it:

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:

.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:

Mission my01
  -> goal A reached: mission my02a
  -> goal B reached: mission my02b

The campaign is saved as .dccamp:

"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.