993 lines
35 KiB
Markdown
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.
|