From 6482ef3294757db911e1d53d49e02d9a5c342ff3 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 17:16:27 +0100 Subject: [PATCH] Document the editors and block scripting Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 26 +- docs/CAMPAIGN_EDITOR.md | 1081 +++-------------------- docs/MISSION_SCRIPTING.md | 1763 +++---------------------------------- 3 files changed, 231 insertions(+), 2639 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6577448..015613e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ But before committing a completed change, the full local checks should pass unle ## Repository conventions - The repository uses Rust with Rhai for mission scripting. -- Campaign editor scripts live mainly in `src/campaign_doc.rs` and `src/script.rs`. +- The campaign editor lives in `src/editor/`, the campaign file format in `src/campaign_doc.rs`, block scripts in `src/blocks.rs` and `src/mscript.rs`. - Main runtime integration lives in `src/main.rs`. - Campaign mission conversion and custom mission gameplay live in `src/campaign.rs`. - UI/editor buttons and menus live in `src/menu.rs` and `src/ui.rs`. @@ -68,23 +68,15 @@ But before committing a completed change, the full local checks should pass unle ## Campaign scripting conventions -- Mission script language: Rhai. +- Mission scripts are **blocks** (`src/blocks.rs`), edited in the in-game block editor (`src/editor/blocks_ui.rs`); players never see code. +- Blocks are translated into Rhai internally and run by `src/mscript.rs` inside the fixed simulation step (deterministic, co-op safe). - The Rust Rhai dependency must keep `no_time` enabled in `Cargo.toml` so the WASM output stays loadable by the macroquad JS bundle. -- Current implemented runtime hook: `on_start(m)`. -- Current implemented script actions: - - `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)` - - `m.spawn_wave(count)` -- Rhai reserves `spawn`, so mission scripts use `spawn_at` for direct spawns. -- Side names currently accepted in script helper calls: - - `"PLAYER"` or side `0` - - `"HEROES"` or side `1` - - `"MOSSGRIM"` or side `2` - - `"NIGHTWELL"` or side `3` - - `"RIVAL3"` or side `4` -- Unknown creature names and unknown zone names should be rejected during script validation. +- Rhai reserves `spawn`; the internal API uses `spawn_unit`. +- Per mission: special scripts (start, victory check, defeat check, clock) and triggers (`TriggerKind` in `src/campaign_doc.rs`). +- New block kinds need: the `Block`/`Val` variant, `template`/`kind_name`, palette entry, checking, Rhai translation, the API function in `mscript.rs`, the editor fields in `blocks_ui.rs`, `blk.*` texts in every language file, and a test. +- Side numbers: -1 any, 0 player, 1 heroes, 2 Mossgrim, 3 Lady Nightwell, 4 rival 3. +- Unknown creature names, zones, triggers and unset variables are reported by `blocks::check`. +- Reference: `docs/CAMPAIGN_EDITOR.md` and `docs/MISSION_SCRIPTING.md`. ## Deployment policy diff --git a/docs/CAMPAIGN_EDITOR.md b/docs/CAMPAIGN_EDITOR.md index d07bcd4..e5d9d58 100644 --- a/docs/CAMPAIGN_EDITOR.md +++ b/docs/CAMPAIGN_EDITOR.md @@ -1,992 +1,141 @@ -# Campaign editor - goals, file format and branches +# Campaign editor -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. +The campaign editor is part of the game: *Campaign editor* in the title menu. It works with the +game's own graphics and needs a mouse and a keyboard (desktop). It has three levels: -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. +1. the **campaign graph** – all missions as cards, linked by arrows, +2. the **mission** – settings, goals and branches, map, scripts, +3. the **map editor** and the **block editor** for one mission. -Throughout this document, **(planned)** marks features that are designed but not implemented yet. +Code: `src/editor/` (`mod.rs` graph and mission pages, `map.rs` map editor, `blocks_ui.rs` block +editor, `widgets.rs` the small interface toolkit, `templates.rs` demo campaign and templates), +the file format in `src/campaign_doc.rs`, the scripts in `src/blocks.rs` and `src/mscript.rs` +(see [MISSION_SCRIPTING.md](MISSION_SCRIPTING.md)). -## 1. Goals +## Campaign graph -The campaign editor is meant to support: +- Top bar: campaign name, author, the campaign's language (one language per campaign), and the + buttons *Play*, *New*, *Open*, *Save file*, *Save online*, *Close*; *+ Mission* adds a card. + *Published* (after saving online) puts the campaign into the public list. +- A card shows the title, random or painted map and its size, the goals, the number of triggers + and, in red, the number of problems in its scripts. +- Click a card to open the mission. Drag a card to move it; drag with the right (or middle) mouse + button to move the view. +- Links: drag from the **gold dot** of a card onto another card – that is the *next mission*. + The **blue dots** are branches; the empty blue ring makes a new branch. Dropping a link on empty + space removes it. The `x` on a card deletes the mission (click twice). +- *Play* plays the whole campaign from the first mission, following next missions and branches; at + the end (or via the pause menu) the editor comes back. -- 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)* +## Opening -## 2. Starting from the title menu +*Open* offers: -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: +- a `.dccamp` file (or drop the file onto the window), +- **Open the demo** – the demo campaign, which uses every trigger kind and every special script, +- **a mission of the built-in campaign as a copy** – its map as it starts, its texts and goals, + added to the current campaign (the original stays untouched), +- **my campaigns (online)** and **published campaigns** of all players (needs a login on a server + with the savegame service). -```text -DungeonCraft +## A mission -- Continue (only if an autosave exists) -- Campaign -- Campaign Editor -- Multiplayer -- New Game -- Load Game -- Settings -- Account: (only when logged in to a server) -- Download Standalone -- Bestiary -- Help -``` +The mission page has four tabs and a **Test play** button. Test play starts the mission with the +script debug view (trigger zones on the map, the variables and a log of which trigger fired when); +*Back to the editor* in the pause menu – or the end of the mission – returns to the editor. -Menu wiring as implemented: +### General -```rust -Btn::CampaignEditor // title menu button -Page::CampaignEditor // the editor page; Back returns to Page::Title -``` +Title, briefing (shown when the mission begins; Enter starts a new paragraph), starting gold, time +limit, and hero waves (number of waves, time to the first wave). -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`. +### Goals -**(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. +- The goals of the mission; it is won when all are reached. Goals the map cannot have (shards on a + map without shards, the gate without a gate, ...) are left out when the mission starts, so they + can never be won by accident. A mission without goals is won only by a script (*Win*). +- *Next mission*. +- *Branches*: another goal (with its number) leads to another mission if it is reached first; + the higher priority wins when several are reached. -## 3. Saving and opening campaign files +Goal kinds: defeat all heroes, take the shards, destroy the gate, raze the knight towers, defeat +the rivals, loot gold (amount), survive waves (count), claim mana springs (count), free creatures +(count). -Campaign files can be passed on to other players. File extension (`CAMPAIGN_EXTENSION`): +### Map -```text -.dccamp -``` +A mission has a **random map** or a **painted map**: -A `.dccamp` file is JSON (pretty-printed by `CampaignDoc::save()`). It contains the complete campaign: +- **Random map**: size, seed, water rivers (0–3), lava rivers (0–2), knight fortresses (0–3), + rival masters (0–2), difficulty and portals. It is rolled **once per run of the campaign** (the + run's seed is kept, so a restarted mission has the same map) – its scripts should use triggers + without fixed places (timers, events, values) or zones that are sure to fit. +- **Generate and paint the map** makes a map from these settings and opens the map editor; when it + is done the mission has a **painted map**: the whole game state is stored in the campaign file, + so it never changes. *Make it a random map* goes back. -- 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 +### Scripts -Example export: +- **Special scripts**: *Start* (once), *Victory check*, *Defeat check* and *Clock* (every second). +- **Triggers**: name, kind, *once* (fires only the first time) and *active* (switched on from the + start). The second line holds the condition: zone, side, unit kind, comparison, number, time, ... + *Blocks* opens the trigger's script. A red frame means a problem (missing zone, script problems). + +## Map editor + +The map in the game's 3D view; tool bar on the left, options on top. + +| Tool | What it does | +|---|---| +| Ground | floor, earth, reinforced wall, rock, water, lava, bridges, gold/silver/ruby/diamond veins, steel; with an owner for floor and walls | +| Rooms | the 14 rooms (and plain floor), with an owner | +| Places | soul core (moves it), portal, hero gate (moves it), mana spring, core shard, rune chest, chained creature (kind, level) | +| Units | creatures and heroes: kind, side (player, heroes, Mossgrim, Lady Nightwell, rival 3), level | +| Towers | arrow, cannon, magic, rune forge tower; the dungeon's or the knights', level 1–3 | +| Traps | the seven traps and three doors, armed | +| Forts | a knight fortress (with or without a shard), rival masters (Mossgrim, Lady Nightwell, rival 3); level = strength | +| Zones | trigger zones: rectangle, circle (drag from the middle), painted tiles (paint onto the selected tiles zone), line (drag from start to end); name, rename, delete | +| Erase | removes units, towers, traps and doors on a tile | + +Options: brush size 1–5, rectangle, fill (connected area of the same ground), mirror (left/right, +top/bottom, both – for fair maps), undo/redo (Ctrl+Z / Ctrl+Y). Camera: right button moves, middle +button turns, wheel zooms, WASD/arrows and Q/E. *Done* stores the map in the mission. + +## Block editor + +See [MISSION_SCRIPTING.md](MISSION_SCRIPTING.md). In short: the palette on the left (categories +Speak, Units, World, Logic, Variables, Mission) – drag a block into the script; move blocks by their +grip `≡`, drop them on the palette to delete them; the fields inside a block are set right there; +the small switch in front of a value changes its kind (number, variable, gold, comparison, ...). +Problems are marked red with a tooltip; the help line explains the block under the pointer. +Ctrl+Z/Y undo/redo, Ctrl+C/V copy/paste the selected block, Delete removes it, Ctrl+F searches. + +## Saving and sharing + +- **File**: `.dccamp` (JSON). A campaign with painted maps can be a few MB. +- **Online** (server with the savegame service): own campaigns per user, overwrite on the next + save; *Published* adds the campaign to the list every logged-in player sees and can open (as a + copy) and play. Server API: `/api/campaigns`, `/api/public/campaigns` (see `server/src/main.rs`). + +## File format (`src/campaign_doc.rs`) ```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.'); }" - } - } - ] + "format": "dungeoncraft.campaign", "version": 2, + "name": "...", "author": "...", "language": "de", + "missions": [{ + "key": "m01", "title": "...", "story": "...", "size": [96, 80], "seed": 101, + "gold": 3000, "portals": 1, "goals": ["KillAll", "Loot(3000)"], + "waves": false, "wave_limit": 0, "first_wave": 300, "time_limit": 0, + "next": "m02", "branches": [{ "id": "b1", "goal": "Loot(5000)", "next": "m03", "priority": 1 }], + "zones": [{ "name": "gate", "shape": "rect", "from": [40, 20], "to": [44, 24] }], + "map": { "kind": "Random" }, // or { "kind": "Fixed", "game": "" } + "gen": { "rivers": 1, "lava": 0, "forts": 1, "rivals": 0, "difficulty": 1 }, + "scripts": { "start": [...], "victory": [...], "defeat": [...], "tick": [...], "triggers": [...] }, + "pos": [40, 40] // the card in the graph + }] } ``` -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 .dccamp -Start - implemented (Btn::CampaignStart), test-plays the selected mission -``` - -How this works per platform: - -- Browser, save: the file is downloaded as `.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 `. ` 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. +Zone coordinates are tiles. Shapes: `rect` (`from`/`to`, inclusive), `circle` (`at`, `radius`), +`tiles` (list), `line` (`from`/`to`). Version 1 files still load; their text scripts are not run. diff --git a/docs/MISSION_SCRIPTING.md b/docs/MISSION_SCRIPTING.md index f1d2d48..fd4495a 100644 --- a/docs/MISSION_SCRIPTING.md +++ b/docs/MISSION_SCRIPTING.md @@ -1,1667 +1,118 @@ -# Mission scripting with Rhai +# Mission scripting with blocks -This guide describes the scripting language for missions and the level/campaign editor. The language is **Rhai**, because it runs directly in Rust, works in WASM, has a simple syntax and is easy to sandbox. +Missions of editor campaigns are scripted with **blocks** (like Blockly): coloured puzzle pieces +put together in the block editor (see [CAMPAIGN_EDITOR.md](CAMPAIGN_EDITOR.md)). Players never see +code – internally the blocks are translated into Rhai, compiled once per mission and run inside the +game's fixed simulation step, so a co-op match runs them identically on every computer. -## Status +Code: `src/blocks.rs` (blocks, values, checking, translation), `src/mscript.rs` (run time, triggers, +zones), `src/campaign_doc.rs` (`MissionScripts`, `TriggerDoc`, `TriggerKind`). -The document is a design draft plus a first implementation. Most of the API below is **planned**; sections and examples that use planned parts are marked with **Planned**. What exists today: +## Scripts of a mission -**Runtime** (`src/script.rs`, applied in `start_custom_script()` in `src/main.rs`) - -- Only missions of a custom campaign (`.dccamp`, `MissionDoc.script`) run scripts. The script language must be `rhai` (or empty); any other value shows `msg.script_language`. -- One hook: `on_start(m)`, called once when the mission starts. A script without `fn on_start(` is allowed and does nothing. -- The script records actions while it runs; the game applies them right after the hook returns. -- The engine stops a script after 200,000 operations (endless loops become a script error). -- Rhai errors (syntax, unknown function, wrong argument types, too many operations) are shown in game as `msg.script_error`. - -**Actions on `m`** - -| Call | Effect | +| Script | When it runs | |---|---| -| `m.log(text)` | shows `text` as a message (`msg.script`) | -| `m.objective(text)` | shows `Objective: text` as a message (`msg.script_objective`); the last call wins | -| `m.spawn_at(side, kind, x, y, count, level)` | spawns `count` creatures of `kind` at the world position `x`, `y` | -| `m.spawn_count_in_zone(zone, kind, count, side, level)` | spawns `count` creatures of `kind` spread over the named zone | -| `m.spawn_wave(count)` | turns the mission's hero waves on, starts the next wave right away and makes sure at least `count` more waves may come | +| Start | once, when the mission begins | +| Victory check | every second – use *If ... Win the mission* | +| Defeat check | every second – use *If ... Lose the mission* | +| Clock | every second | +| Triggers | whenever their condition is met (see below) | -Argument details: +The normal goals of the mission still apply; scripts are for anything more complicated. Any script +may call *Win* or *Lose*. -- `side`: an integer `0` to `4`, or a name: `"PLAYER"` (0), `"HEROES"` (1, also `"HERO"`), `"MOSSGRIM"` (2), `"NIGHTWELL"` (3), `"RIVAL3"` (4, also `"RIVAL"`). Names are case-insensitive. -- `kind`: a creature name from the list in [Creatures](#creatures), case-insensitive. -- `x`, `y`: **floating point** world coordinates in tiles (`21.5` is the centre of tile 21). Integer literals do not match: `m.spawn_at("HEROES", "Knight", 21, 21, 3, 2)` fails with "function not found"; write `21.0` or `21.5`. Positions outside the map are skipped silently. -- `count`, `level`, `side` (as number): integers. `count` and `level` are clamped to `1..10`; `spawn_wave` clamps `count` to `1..20`. -- `zone`: the `name` of a zone in the mission's `zones` list (see [Zones in the mission definition](#zones-in-the-mission-definition)). +## Triggers -**Validation** (`validate_script_source()` in `src/main.rs`) +Each trigger has a **name**, a **kind** with its condition, **once** (fires only the first time; +switched on again by a *Switch trigger* block it may fire once more) and **active** (switched on at +the start). Conditions are checked ten times a second. -When a script is entered in the campaign editor (*Edit script*, up to 600 characters), it is run once with `on_start` and rejected if: - -- Rhai reports an error (syntax error, unknown function, wrong argument types, too many operations), -- a spawn uses an unknown creature name, -- `spawn_count_in_zone` names a zone that the mission does not define. - -Not checked yet: unknown side names (such a spawn is dropped silently), unknown side numbers (reported only in game), coordinates outside the map (skipped silently). Scripts loaded from a `.dccamp` file are not validated; the same errors then show up in game as `msg.script_error`. - -**Zones**: missions can store zones as data (`zones` in the `.dccamp` file, shapes `circle`, `rect`, `line`, `tiles`). They are used by `spawn_count_in_zone` and drawn by the *Debug trigger zones* setting. There is no graphical zone editor yet and no zone trigger hooks. - -**Not implemented yet**: all other hooks, readable properties on `m`, flags/variables/counters, hints/warnings/effects, position helpers (`m.at`, ...), zone functions in the script, destruction actions, random functions, limit changes, goal and branch objects, and script files outside the campaign file. - -**Rhai notes** - -- `spawn` is a reserved word in Rhai. Even `m.spawn(...)` is a parse error, so the API uses `spawn_at`. -- Strings use double quotes. `'x'` is a single character in Rhai, and `'abc'` is a syntax error. -- Functions cannot see variables or constants of the main script. A top-level `const HEROES = 1;` must be written as `global::HEROES` inside a function; plain `HEROES` fails with "variable not found". The side names as strings (`"HEROES"`) avoid this. - -## 1. What mission scripting is for - -Mission scripting allows missions that are more than fixed data: - -- Timed events: after 300 seconds reinforcements attack. -- Reactions: when the player loots a vault, a paladin appears. -- Phases: first waves, then a fortress, then a boss. -- Controlled randomness: a patrolling group takes one of two paths. -- Dynamic briefings and objectives. -- Rivals, caves, shards, mana springs and gates can react to the player's progress. -- Campaign missions can later be edited with their own story, goals and logic. - -Important: scripts change missions, they do not replace the whole game. They therefore run in a sandbox. - -## 2. Language: Rhai - -Mission scripts are written in Rhai. - -Example: - -```rhai -const HERO = 1; -const MOSSGRIM = 2; -const NIGHTWELL = 3; - -fn on_start(m) { - m.log("Crookfinger has a plan."); - m.objective("Find the shard before the guards raise the alarm."); -} -``` - -Rhai is deliberately simple: - -- Variables with `let`. -- Constants with `const`. -- Functions with `fn`. -- `if`, `for`, `while`, booleans, numbers and strings (in double quotes). -- Arrays with `[...]`. -- Maps with `#{...}`. -- No files, no network access, no direct access to Rust memory. - -Constants declared at the top are only visible inside functions as `global::NAME`: - -```rhai -const HEROES = 1; - -fn on_start(m) { - m.spawn_at(global::HEROES, "Knight", 21.5, 21.5, 2, 1); // works - // m.spawn_at(HEROES, ...) would fail: functions do not see HEROES -} -``` - -## 3. Where a mission script lives - -A mission consists of data and optionally a script. Today the script is stored inside the mission in the campaign file (`.dccamp`) as an object with `language` and `source`: - -```json -{ - "key": "custom01", - "title": "The first trap", - "story": "The guards believe the old shaft is safe. They are wrong.", - "size": [96, 80], - "seed": 123456, - "gold": 2500, - "goals": ["Shards", "Survive(3)"], - "script": { - "language": "rhai", - "source": "fn on_start(m) { m.objective(\"Find the shard.\"); }" - } -} -``` - -In the campaign editor the script is edited with *Edit script* in a single-line text prompt. **Planned**: a larger text box and scripts in separate files such as `missions/custom01.rhai` (`"script": "missions/custom01.rhai"`). - -The campaign file stores the whole structure: - -```json -{ - "format": "dungeoncraft.campaign", - "version": 1, - "name": "My campaign", - "author": "", - "missions": [ - { - "key": "custom01", - "title": "The first trap", - "story": "Short text for the briefing.", - "goals": ["Shards"], - "script": { "language": "rhai", "source": "fn on_start(m) { m.objective(\"Find the shard.\"); }" } - }, - { - "key": "custom02", - "title": "The second wave", - "story": "After the shard the Order comes back.", - "goals": ["Survive(4)"], - "script": { "language": "rhai", "source": "fn on_start(m) { m.spawn_wave(2); }" } - } - ] -} -``` - -See `docs/CAMPAIGN_EDITOR.md` for the full campaign format. - -## 4. The mission object `m` - -Every hook receives a mission object `m`. It provides the mission, the game state and the allowed actions. Today `m` only offers the actions listed in [Status](#status). - -### Readable properties - -**Planned** - none of these properties exist yet; reading them is a script error. - -| Field | Meaning | -|---|---| -| `m.time` | elapsed mission time in seconds | -| `m.map_w` | map width in tiles | -| `m.map_h` | map height in tiles | -| `m.gold` | gold of the currently active master | -| `m.mana` | mana of the currently active master | -| `m.loot` | vault gold looted so far | -| `m.shards_taken` | soul core shards taken | -| `m.springs` | mana springs claimed | -| `m.freed` | chained creatures freed | -| `m.wave` | current or last hero wave number | -| `m.time_limit` | time limit in seconds, `0` means no limit | -| `m.wave_limit` | wave limit, `0` means endless | -| `m.gate_hp` | hit points of the Order's gate | -| `m.state` | `0` running, `1` victory, `2` defeat | - -### Sides - -Implemented for `spawn_at` and `spawn_count_in_zone`: - -| Name | Number | Meaning | +| Kind | Condition | The "trigger unit" | |---|---|---| -| `"PLAYER"` | `0` | red master, normally the player | -| `"HEROES"` (or `"HERO"`) | `1` | the Order of knights | -| `"MOSSGRIM"` | `2` | green rival Mossgrim | -| `"NIGHTWELL"` | `3` | blue rival Lady Nightwell | -| `"RIVAL3"` (or `"RIVAL"`) | `4` | third rival, yellow | - -The names are strings, not built-in constants. Either pass the string, or declare your own constants and use them as `global::NAME` inside functions: - -```rhai -const HEROES = 1; -const MOSSGRIM = 2; -const NIGHTWELL = 3; -``` - -### Creatures - -Creature names as strings, matching the internal names (`parse_spawn_kind()` in `src/script.rs`, case-insensitive): - -```rhai -"Worker" -"Kobold" -"Spider" -"Mage" -"Ogre" -"Knight" -"Archer" -"Priest" -"Dwarf" -"Paladin" -"Jailer" -"Priestess" -"Chainbreaker" -"Bat" -"Gargoyle" -"Owl" -"Wyrm" -"Cogling" -"Revenant" -"Giant" -"Fairy" -"BattleMage" -``` - -Any other name is rejected by the editor validation. - -Example: - -```rhai -m.spawn_at("HEROES", "Knight", 69.5, 38.5, 1, 2); -``` - -### Goals - -**Planned** - goal objects and `on_goal` do not exist yet. - -Mission goals can be handled as goal objects: - -```rhai -goal.name -goal.count -goal.done -``` - -Names (the same names as in the mission's `goals` list; `Loot`, `Survive`, `Springs` and `Freed` take a number there, e.g. `Survive(3)`): - -```rhai -Shards -Heart -KillAll -Loot -Towers -Survive -Rivals -Springs -Freed -``` - -Example: - -```rhai -if goal.name == "Shards" && goal.done { - m.objective("Second stage: the fortress."); -} -``` - -## 5. Allowed actions - -The allowed functions are deliberately few and safe. - -### Text and UI - -Implemented: `m.log` and `m.objective`. **Planned**: `m.hint` and `m.warn`. - -```rhai -m.log("Message in the log."); -m.hint("Hint for the player."); // planned -m.warn("Warning with alarm sound."); // planned -m.objective("Current objective in the interface."); -``` - -`m.objective` currently shows the text as a message (`Objective: ...`), not in a separate objective panel. - -### Effects - -**Planned.** - -```rhai -m.play("alarm"); -m.flash("danger"); -m.flash("treasure"); -``` - -Planned effect names: - -```rhai -alarm -danger -treasure -claim -level_up -heal -``` - -### Variables, flags and counters - -**Planned.** - -```rhai -m.set("phase", 1); -m.get("phase"); -m.has("phase"); - -m.set_flag("guards_alerted"); -m.has_flag("guards_alerted"); -m.clear_flag("guards_alerted"); - -m.bump("vaults_opened"); -m.counter("vaults_opened"); -``` - -Rule: every action that should happen only once is guarded by a flag or a counter. - -### Positions - -Implemented: `m.spawn_at` takes plain world coordinates in tiles (`x`, `y` as floats; `x + 0.5` is the centre of tile `x`). Zones use tile coordinates too. - -**Planned**: position helpers. In the editor, coordinates are best given as fractions of the map: - -```rhai -m.at(0.70, 0.50) -``` - -This means 70 percent of the map width and 50 percent of the map height. The game converts this to tile coordinates. - -More planned position helpers: - -```rhai -m.map_center() -m.fort_pos(0) -m.cave_pos(0) -m.gate_pos() -m.core_pos(0) -``` - -To use these positions for spawning, a planned `spawn_at` variant takes a position instead of `x`, `y`: - -```rhai -m.spawn_at("HEROES", "Knight", m.at(0.72, 0.48), 1, 2); // planned -``` - -The examples below use plain tile coordinates where possible and assume a 96x80 map (`m.at(0.70, 0.50)` is about `67.5, 40.5` there). - -### Trigger zones, marked tiles and lines - -Location triggers are named places, areas or paths in the mission. The script can react when a creature reaches such a place or crosses a line, spawn creatures in a zone, or cause destruction in the zone. - -Implemented: zones stored in the mission data (`zones` in the `.dccamp` file) and `m.spawn_count_in_zone`. Everything else in this section is **planned**: defining zones in the script, drawing them in the editor, zone triggers and destruction actions. - -#### Defining zones in the script - -**Planned.** - -```rhai -fn on_start(m) { - m.add_zone("north_gate", m.at(0.70, 0.45), 2.0); - m.add_zone("south_gate", m.at(0.70, 0.55), 2.0); - m.add_zone("vault_approach", m.at(0.82, 0.50), 3.0); -} -``` - -Areas made of marked tiles: - -```rhai -fn on_start(m) { - m.add_tiles_zone("vault_path", [ - [34, 40], - [34, 41], - [35, 40] - ]); -} -``` - -Rectangles and lines: - -```rhai -fn on_start(m) { - m.add_rect_zone("guard_room", m.at(0.70, 0.40), m.at(0.80, 0.60)); - m.add_line("bridge_path", [m.at(0.60, 0.50), m.at(0.72, 0.50)], 1.0); -} -``` - -#### Drawing zones in the editor - -**Planned** - the campaign editor cannot draw zones yet; zones are written into the `.dccamp` file by hand. - -The level/campaign editor can place zones graphically: - -- Circle: drag centre and radius. -- Rectangle: drag two corners. -- Polygon: click points. -- Marked tiles: paint single tiles or an area. -- Line: draw a bridge, path, patrol route or border line. -- Optional filter: side, creature type, once only, player creatures only, enemy creatures only. - -Zones placed graphically are stored in the mission data and made available to the script automatically. The script does not have to define them by hand, but it can always add more zones. - -Example script call: - -```rhai -fn on_start(m) { - m.load_editor_zones(); -} -``` - -After `m.load_editor_zones()` all zones drawn in the editor are available in the script. Additional triggers can be added afterwards: - -```rhai -fn on_start(m) { - m.load_editor_zones(); - - m.add_zone("backup_path", m.at(0.58, 0.50), 2.0); -} -``` - -The editor can also show the generated part of the script: - -```rhai -// --- editor-generated zones: normally not edited manually --- -fn editor_zones(m) { - m.add_zone("north_gate", m.at(0.70, 0.50), 3.0); - m.add_tiles_zone("vault_path", [[34, 40], [34, 41], [35, 40]]); -} -``` - -The mission script then simply calls `editor_zones(m)`. - -#### Zones in the mission definition - -Implemented. Zones can be stored directly in the mission definition. All coordinates are **absolute tile coordinates** (not map fractions): - -```json -"zones": [ - { - "name": "north_gate", - "shape": "circle", - "at": [67, 36], - "radius": 2.0 - }, - { - "name": "vault_approach", - "shape": "circle", - "at": [78, 40], - "radius": 3.0 - }, - { - "name": "guard_room", - "shape": "rect", - "from": [67, 32], - "to": [77, 48] - }, - { - "name": "vault_path", - "shape": "tiles", - "tiles": [[34, 40], [34, 41], [35, 40]] - }, - { - "name": "bridge_path", - "shape": "line", - "from": [58, 40], - "to": [69, 40], - "width": 1.0 - } -] -``` - -| Shape | Fields | Spawn positions of `spawn_count_in_zone` | +| Zone entered | a unit of a side/kind comes into a zone | that unit | +| Zone left | a unit of a side/kind leaves a zone | that unit | +| Line crossed | a unit crosses a line zone (from one side to the other, within its ends) | that unit | +| Zone claimed | the player claims more tiles in a zone | – | +| Dug in the zone | fewer solid tiles in a zone (dug, caved in) | – | +| Units in the zone | the count of a side/kind in a zone becomes `< <= = != >= >` n | – | +| Timer | after n seconds, or every n seconds (*repeat*) | – | +| A unit falls | a unit of a side/kind dies (or the unit with this name, see *Spawn*) | the fallen unit (its place) | +| A tower falls | a tower of the dungeon / the knights / anyone is destroyed | – | +| A value is reached | gold, mana, creatures, heroes or a variable becomes `op n` | – | +| Room built | a room of a kind (or any) grows | – | +| Spell cast | a spell (or any) is cast | – | +| Creature slapped | the player slaps one of his creatures | – | +| Research done | the library finishes a discovery | – | + +"Becomes" means the trigger fires when the condition turns true, not every check while it stays true. +Sides: player, heroes, Mossgrim, Lady Nightwell, rival 3, anyone. Kinds: any, or one of the 22 +creatures and heroes. + +## Blocks + +| Category | Block | Fields | |---|---|---| -| `circle` (default) | `at`, `radius` (default `2.0`); without `at` the zone sits on the player's core | evenly on a ring inside the radius, or the centre for one creature | -| `rect` (also `rectangle`, `square`) | `from`, `to` (corner tiles) | a grid across the rectangle | -| `line` (also `path`) | `from`, `to`, `width` (only drawn) | evenly along the line | -| `tiles` | `tiles` (list of `[x, y]`) | the first `count` tiles of the list | - -Positions outside the map are dropped. If none is left, the game reports `no usable spawn position in zone`. **Planned**: a `polygon` shape. - -#### Zone actions in the script - -Once a zone is defined, the script can trigger actions in it. Implemented: `m.spawn_count_in_zone`. **Planned**: `m.spawn_in_zone`, the `destroy_*` functions and `m.warn`. - -```rhai -m.spawn_in_zone("north_gate", "Knight", 3, "HEROES"); // planned -m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2); -m.spawn_count_in_zone("north_gate", "Archer", 3, "HEROES", 1); - -m.destroy_walls_in_zone("north_gate"); // planned -m.destroy_walls_at(m.at(0.72, 0.50), 2.0); // planned - -m.destroy_doors_in_zone("vault_path"); // planned -m.destroy_towers_in_zone("north_gate"); // planned - -m.log("The zone is open."); -m.warn("The knights are coming through the gap!"); // planned -``` - -Example (**planned**: zone triggers, flags, `m.warn`, `destroy_*`, `m.fort_pos`): a creature reaches a graphically placed zone, the wall falls, and enemies appear behind it. - -```rhai -const HEROES = 1; -const PLAYER = 0; - -fn on_start(m) { - m.load_editor_zones(); - m.log("A marked zone has been placed in the editor."); -} - -fn on_unit_reaches(m, unit, zone) { - if zone != "north_gate" { - return; - } - - if unit.side != global::PLAYER { - return; - } - - if m.has_flag("north_gate_opened") { - return; - } - - m.set_flag("north_gate_opened"); - m.log("A creature has reached the north gate."); - m.warn("Knights are waiting behind the wall!"); - - m.destroy_walls_in_zone("north_gate"); - m.spawn_count_in_zone("north_gate", "Knight", 3, global::HEROES, 2); - m.spawn_at(global::HEROES, "Archer", m.fort_pos(0), 1, 3); -} -``` - -These functions must not edit arbitrary tiles. They only work through the mission API and are deterministic. - -### Spawning creatures and waves - -Implemented (`spawn` is reserved in Rhai, so the name is `spawn_at`): - -```rhai -m.spawn_at(side, kind, x, y, count, level); -m.spawn_count_in_zone(zone, kind, count, side, level); -``` - -Example: - -```rhai -m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2); -m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2); -``` - -```rhai -m.spawn_at("HEROES", "Knight", 69.5, 38.5, 1, 2); -``` - -Groups through several calls: - -```rhai -m.spawn_at("HEROES", "Knight", 69.5, 36.5, 1, 2); -m.spawn_at("HEROES", "Archer", 71.5, 44.5, 1, 2); -``` - -Creatures of side `1` are spawned as heroes; all other sides spawn creatures for that master. - -Waves: - -Implemented: `m.spawn_wave(count)`. It turns the mission's waves on, starts the next wave right away, and, if the mission has a wave limit, raises the limit so that at least `count` more waves can come (a limit of `0` stays endless): - -```rhai -m.spawn_wave(6); -``` - -**Planned**: - -```rhai -m.set_wave_limit(8); -m.set_first_wave(180); -``` - -Changing goals and limits (**planned**): - -```rhai -m.set_time_limit(1200); -m.add_time_limit(300); -m.set_wave_limit(10); -``` - -### Randomness - -**Planned.** Only use the allowed random functions: - -```rhai -m.rand_int(1, 10) -m.rand_choice(["north", "south"]) -``` - -Example: - -```rhai -let path = m.rand_choice(["north", "south"]); -m.set("patrol_path", path); -``` - -## 6. Event hooks - -A mission script can define several hooks. Not every hook has to be present. - -Implemented: only `on_start(m)`. All other hooks are **planned**; defining them today is allowed but they are never called. - -| Hook | When | Status | -|---|---|---| -| `on_start(m)` | mission start | implemented | -| `on_time(m)` | regularly during the mission, recommended: once per second | planned | -| `on_turn(m, turn)` | in matches, when a server turn arrives | planned | -| `on_wave_start(m)` | before a new wave | planned | -| `on_wave(m, wave)` | after a wave has started | planned | -| `on_vault_claimed(m, vault_index, value)` | when a knight vault has been captured | planned | -| `on_shard_taken(m, count)` | when a shard has been taken | planned | -| `on_tower_destroyed(m, kind)` | when a tower has been destroyed | planned | -| `on_fort_alarm(m, fort_index)` | when a fortress raises the alarm | planned | -| `on_rival_attacked(m, side)` | when a rival is attacked directly | planned | -| `on_rival_dead(m, side)` | when a rival's core is destroyed | planned | -| `on_cave_claimed(m, cave_index, chained)` | when a cave is claimed | planned | -| `on_freed(m, kind, cave_index)` | when a chained creature is freed | planned | -| `on_gate_damage(m, damage)` | when the Order's gate is hit | planned | -| `on_unit_reaches(m, unit, zone)` | when a creature reaches a trigger zone or a target | planned | -| `on_unit_crosses(m, unit, line)` | when a creature crosses a trigger line | planned | -| `on_branch(m, branch)` | when a branch goal has been reached and the next mission is decided | planned | -| `on_goal(m, goal)` | when a mission goal changes noticeably or is reached | planned | -| `on_end(m, won)` | mission end | planned | - -### The `unit` object in location triggers - -**Planned.** In `on_unit_reaches` and `on_unit_crosses` the script receives a small `unit` object: - -```rhai -unit.id -unit.kind -unit.level -unit.side -unit.alive -unit.x -unit.y -``` - -Example: - -```rhai -if unit.kind == "Ogre" && unit.side == global::PLAYER { - m.log("An ogre has reached the zone."); -} -``` - -`zone` is the name of the zone placed in the editor or defined in the script: - -```rhai -if zone == "north_gate" { - // only react to this zone -} -``` - -Trigger zones should always be guarded by flags or counters so that the same action does not fire several times. - -### Mission branches in the script - -**Planned.** If a mission has branch goals, the script can react to a reached branch: - -```rhai -fn on_branch(m, branch) { - m.log("Two paths: " + branch.id); - m.objective("The story continues on the path " + branch.id); -} -``` - -A branch object can later have these fields (the same as a branch in the mission data): - -```rhai -branch.id -branch.title -branch.goal -branch.next -``` - -Example for two alternative paths: - -```rhai -fn on_branch(m, branch) { - if branch.goal == "KillAll" { - m.log("The guardroom is destroyed."); - m.objective("The path leads to the patrol routes."); - } - - if branch.goal == "Loot" { - m.log("The vaults are looted."); - m.objective("The path leads to the gold chambers."); - } -} -``` - -The branches themselves (choosing the next mission by goal) are implemented in the campaign data; see `docs/CAMPAIGN_EDITOR.md`. - -## 7. Basic rule: everything must be repeatable - -**Planned** (flags, `on_fort_alarm`, `m.play`). Events can fire several times. A script should therefore use flags or counters as a matter of habit. - -Example: an alarm should fire exactly once. - -```rhai -fn on_fort_alarm(m, fort_index) { - let key = "alarm_fort_" + fort_index.to_string(); - - if !m.has_flag(key) { - m.set_flag(key); - m.log("The guard has raised the alarm!"); - m.play("alarm"); - } -} -``` - -Helper pattern for one-time actions: - -```rhai -fn once(m, key, action) { - if !m.has_flag(key) { - m.set_flag(key); - action.call(); - } -} -``` - -Usage: - -```rhai -fn on_start(m) { - once(m, "intro", || { - m.log("Crookfinger: We have guests."); - m.objective("Explore the shaft."); - }); -} -``` - -A Rhai closure is called with `action.call()`. - -## 8. Example 1: Minimal start script - -Works today up to `on_goal`, which is **planned** (it is never called yet). - -```rhai -fn on_start(m) { - m.log("Crookfinger: A small outpost, but maybe a shard."); - m.objective("Find the shard."); -} - -fn on_goal(m, goal) { - if goal.name == "Shards" && goal.done { - m.log("Shard secured."); - m.objective("The way to the citadel is open."); - } -} -``` - -## 9. Example 2: Timed reinforcements - -**Planned** (`on_time`, `m.time`, flags, `m.warn`). - -This mission makes knights appear from the east after five minutes. After ten minutes a second group comes. - -```rhai -fn on_time(m) { - if m.time >= 300 && !m.has_flag("wave_5min") { - m.set_flag("wave_5min"); - m.log("Reinforcements from the east."); - m.spawn_at("HEROES", "Knight", 67.5, 38.5, 1, 2); - m.spawn_at("HEROES", "Archer", 67.5, 44.5, 1, 2); - } - - if m.time >= 600 && !m.has_flag("wave_10min") { - m.set_flag("wave_10min"); - m.log("Now they mean it."); - m.spawn_at("HEROES", "Knight", 71.5, 36.5, 1, 3); - m.spawn_at("HEROES", "Knight", 71.5, 48.5, 1, 3); - m.warn("Second wave!"); - } -} -``` - -## 10. Example 3: Three phases of a mission - -**Planned** (`on_time`, `m.time`, flags, `m.warn`). - -This mission has three stages. Each stage fires exactly once. - -```rhai -fn phase_name(m, phase) { - if phase == 1 { - return "First phase"; - } - - if phase == 2 { - return "Second phase"; - } - - return "Third phase"; -} - -fn on_time(m) { - let phase = 0; - - if m.time >= 900 { - phase = 3; - } else if m.time >= 600 { - phase = 2; - } else if m.time >= 300 { - phase = 1; - } - - let key = "phase_" + phase.to_string(); - - if phase > 0 && !m.has_flag(key) { - m.set_flag(key); - m.log(phase_name(m, phase)); - - if phase == 1 { - m.spawn_wave(3); - m.objective("Hold off the first small wave."); - } - - if phase == 2 { - m.spawn_wave(5); - m.spawn_at("HEROES", "Paladin", 77.5, 40.5, 1, 2); - m.objective("Beat back the reinforcements."); - } - - if phase == 3 { - m.spawn_wave(8); - m.warn("The Order comes with everything it has."); - m.objective("Survive the last assault."); - } - } -} -``` - -## 11. Example 4: Vault triggers a boss - -**Planned** (`on_vault_claimed`, counters, flags, `m.warn`). - -When the player loots enough vaults, a strong enemy appears. - -```rhai -fn on_vault_claimed(m, vault_index, value) { - m.log("Vault " + vault_index.to_string() + " is open: " + value.to_string()); - m.bump("opened_vaults"); - - if m.counter("opened_vaults") == 2 { - m.set_flag("second_vault_alarm"); - m.log("The guardian is summoned."); - m.spawn_at("HEROES", "Paladin", 79.5, 40.5, 1, 4); - m.warn("A paladin approaches."); - } - - if m.counter("opened_vaults") >= 4 { - m.log("The Order sends everything it has left."); - m.spawn_wave(6); - } -} -``` - -## 12. Example 5: Boss fight with several phases - -**Planned** (`on_tower_destroyed`, counters, flags, `m.warn`). - -A paladin is supported by more and more knights. - -```rhai -fn boss_phase(m, phase) { - if phase == 1 { - m.spawn_at("HEROES", "Knight", 73.5, 34.5, 1, 3); - m.spawn_at("HEROES", "Knight", 73.5, 46.5, 1, 3); - m.log("The paladin is escorted by knights."); - } - - if phase == 2 { - m.spawn_at("HEROES", "BattleMage", 79.5, 40.5, 1, 3); - m.warn("A battle mage enters the field."); - } - - if phase == 3 { - m.spawn_at("HEROES", "Giant", 75.5, 40.5, 1, 3); - m.log("A giant smashes the first traps."); - } -} - -fn on_tower_destroyed(m, kind) { - let key = "tower_" + kind.to_string(); - - if !m.has_flag(key) { - m.set_flag(key); - m.bump("towers_destroyed"); - } - - let destroyed = m.counter("towers_destroyed"); - - if destroyed >= 1 && !m.has_flag("boss_1") { - m.set_flag("boss_1"); - boss_phase(m, 1); - } - - if destroyed >= 3 && !m.has_flag("boss_2") { - m.set_flag("boss_2"); - boss_phase(m, 2); - } - - if destroyed >= 5 && !m.has_flag("boss_3") { - m.set_flag("boss_3"); - boss_phase(m, 3); - } -} -``` - -## 13. Example 6: Letting rivals react - -**Planned** (`on_rival_attacked`, `on_rival_dead`, counters, flags). - -A rival strikes back as soon as the player becomes too aggressive. - -```rhai -const MOSSGRIM = 2; -const NIGHTWELL = 3; - -fn on_rival_attacked(m, side) { - m.bump("rival_pressure"); - - if m.counter("rival_pressure") >= 3 && !m.has_flag("mossgrim_retaliation") { - m.set_flag("mossgrim_retaliation"); - m.log("Mossgrim feels your attack."); - m.spawn_wave(6); - } - - if m.counter("rival_pressure") >= 6 && !m.has_flag("nightwell_retaliation") { - m.set_flag("nightwell_retaliation"); - m.log("Lady Nightwell joins in."); - m.spawn_at(global::NIGHTWELL, "Owl", 55.5, 32.5, 1, 3); - m.spawn_at(global::NIGHTWELL, "Owl", 55.5, 48.5, 1, 3); - } -} - -fn on_rival_dead(m, side) { - if side == global::MOSSGRIM { - m.log("Mossgrim's core has shattered."); - } - - if side == global::NIGHTWELL { - m.log("Lady Nightwell's core has shattered."); - } -} -``` - -## 14. Example 7: Freed creatures call the guards - -**Planned** (`on_freed`, flags, `m.warn`, position helpers with `spawn_at`). - -When a chained creature is freed, guards come. - -```rhai -fn on_freed(m, kind, cave_index) { - let key = "cave_freed_" + cave_index.to_string(); - - if !m.has_flag(key) { - m.set_flag(key); - m.log("The chains are broken."); - - if kind == "Ogre" { - m.spawn_at("HEROES", "Knight", m.cave_pos(cave_index), 1, 2); - m.spawn_at("HEROES", "Knight", m.fort_pos(0), 1, 2); - m.warn("The knights have noticed the escape."); - } - - if kind == "Wyrm" { - m.spawn_at("HEROES", "Knight", m.fort_pos(0), 1, 3); - m.spawn_at("HEROES", "Priest", m.fort_pos(0), 1, 3); - m.log("A wyrm is free - and the guards come anyway."); - } - } -} -``` - -## 15. Example 8: Randomness with safe repetition - -**Planned** (`m.rand_choice`, variables, `on_time`, flags). - -A patrol route is chosen at random, but the mission stays reproducible because `m.rand_choice` is used. - -```rhai -fn on_start(m) { - let path = m.rand_choice(["north", "south"]); - m.set("patrol_path", path); - m.log("The patrol takes the route: " + path.to_string()); -} - -fn on_time(m) { - if m.time < 300 || m.has_flag("patrol_arrived") { - return; - } - - let path = m.get("patrol_path"); - - if path == "north" { - m.spawn_at("HEROES", "Knight", 67.5, 24.5, 1, 2); - m.spawn_at("HEROES", "Archer", 69.5, 29.5, 1, 2); - } - - if path == "south" { - m.spawn_at("HEROES", "Knight", 67.5, 56.5, 1, 2); - m.spawn_at("HEROES", "Archer", 69.5, 51.5, 1, 2); - } - - m.set_flag("patrol_arrived"); -} -``` - -## 16. Example 9: Time limit warnings - -**Planned** (`on_time`, `on_end`, `m.time`, `m.time_limit`, flags, `m.warn`). - -A mission with a time limit warns shortly before it runs out. - -```rhai -fn on_time(m) { - if m.time_limit <= 0 { - return; - } - - let left = m.time_limit - m.time; - - if left <= 300 && !m.has_flag("warn_5min") { - m.set_flag("warn_5min"); - m.warn("Five minutes left."); - } - - if left <= 60 && !m.has_flag("warn_last_minute") { - m.set_flag("warn_last_minute"); - m.warn("The last minute is running."); - } -} - -fn on_end(m, won) { - if !won { - m.log("Time has run out."); - } -} -``` - -## 17. Example 10: Dynamic briefing texts - -`on_start` works today; `on_goal` is **planned**. - -A script can change objectives without restarting the mission file. - -```rhai -fn on_start(m) { - m.objective("Explore the shaft."); -} - -fn on_goal(m, goal) { - if goal.name == "Shards" && !goal.done { - m.objective("Shard found, but not secured yet."); - } - - if goal.name == "Shards" && goal.done { - m.objective("Secure the way back to the core."); - } - - if goal.name == "Survive" && goal.done { - m.objective("The waves are beaten."); - } -} -``` - -### Example 11: Drawn zone: the wall falls, enemies appear - -**Planned** (drawing in the editor, `load_editor_zones`, `on_unit_reaches`, flags, `m.warn`, `destroy_walls_in_zone`, `m.fort_pos`). The zone data itself already works. - -A circle named `north_gate` is drawn in the level editor (tile coordinates): - -```json -"zones": [ - { - "name": "north_gate", - "shape": "circle", - "at": [67, 40], - "radius": 3.0 - } -] -``` - -The mission script loads the editor zones automatically: - -```rhai -const HEROES = 1; -const PLAYER = 0; - -fn on_start(m) { - m.load_editor_zones(); - m.objective("Find the way into the fortress."); -} - -fn on_unit_reaches(m, unit, zone) { - if zone != "north_gate" { - return; - } - - if unit.side != global::PLAYER { - return; - } - - if m.has_flag("north_gate_opened") { - return; - } - - m.set_flag("north_gate_opened"); - m.log("A creature has reached the north gate."); - m.warn("The wall breaks!"); - - m.destroy_walls_in_zone("north_gate"); - m.spawn_count_in_zone("north_gate", "Knight", 3, global::HEROES, 2); - m.spawn_at(global::HEROES, "Archer", m.fort_pos(0), 1, 3); -} -``` - -If the editor writes the zones into the script automatically, `on_start(m)` can simply look like this: - -```rhai -fn on_start(m) { - editor_zones(m); - m.objective("Find the way into the fortress."); -} -``` - -The editor-generated part would then be: - -```rhai -// --- editor-generated zones: normally not edited manually --- -fn editor_zones(m) { - m.add_zone("north_gate", m.at(0.70, 0.50), 3.0); -} -``` - -### Example 12: Marked tiles as a zone - -**Planned** (`load_editor_zones`, `on_unit_reaches`, flags, `m.warn`, `destroy_doors_in_zone`, `add_tiles_zone`). The zone data and `spawn_count_in_zone` already work. - -Several tiles are marked in the level editor, for example a path to a vault: - -```json -"zones": [ - { - "name": "vault_path", - "shape": "tiles", - "tiles": [[34, 40], [34, 41], [35, 40], [35, 41]] - } -] -``` - -The script reacts as soon as a player creature steps on these marked tiles: - -```rhai -const HEROES = 1; -const PLAYER = 0; - -fn on_start(m) { - m.load_editor_zones(); - m.log("Crookfinger has marked a path."); -} - -fn on_unit_reaches(m, unit, zone) { - if zone != "vault_path" { - return; - } - - if unit.side != global::PLAYER { - return; - } - - if m.has_flag("vault_alarm") { - return; - } - - m.set_flag("vault_alarm"); - m.log("Someone has entered the marked path."); - m.warn("Vault alarm!"); - - m.destroy_doors_in_zone("vault_path"); - m.spawn_count_in_zone("vault_path", "Knight", 2, global::HEROES, 3); -} -``` - -If the zone is not placed in the editor, the same zone can also be defined in the script: - -```rhai -fn on_start(m) { - m.add_tiles_zone("vault_path", [ - [34, 40], - [34, 41], - [35, 40], - [35, 41] - ]); -} -``` - -### Example 13: Trigger line - a creature crosses a bridge - -**Planned** (`load_editor_zones`, `on_unit_crosses`, counters, `m.warn`). The line zone data already works. - -A line across a bridge is drawn in the editor (tile coordinates): - -```json -"zones": [ - { - "name": "bridge_path", - "shape": "line", - "from": [58, 40], - "to": [69, 40], - "width": 1.0 - } -] -``` - -The script sets off ambushes when a creature crosses the bridge: - -```rhai -const PLAYER = 0; - -fn on_start(m) { - m.load_editor_zones(); - m.objective("The bridge is not safe."); -} - -fn on_unit_crosses(m, unit, line) { - if line != "bridge_path" { - return; - } - - if unit.side != global::PLAYER { - return; - } - - if m.counter("bridge_ambushes") >= 2 { - return; - } - - m.bump("bridge_ambushes"); - m.log("The bridge is being watched."); - m.warn("Ambush!"); - - m.spawn_at("HEROES", "Knight", 69.5, 36.5, 1, 2); - m.spawn_at("HEROES", "Archer", 71.5, 44.5, 1, 2); -} -``` - -## 18. Mission definition with script and briefing - -A complete example. **Planned** parts: the `hints` field and scripts in separate files (today the script goes into `"script": { "language": "rhai", "source": "..." }`), and every hook except `on_start`. - -```json -{ - "key": "custom01", - "title": "The first trap", - "story": "The guards believe the old shaft is safe. They are wrong. Crookfinger has built a trap, but it needs bait.", - "hints": [ - "Towers do not walk away on their own.", - "Knight ground can only be claimed when no knights stand next to it." - ], - "size": [96, 80], - "seed": 123456, - "gold": 2500, - "goals": ["Shards", "Survive(3)"], - "zones": [ - { - "name": "north_gate", - "shape": "circle", - "at": [67, 40], - "radius": 3.0 - }, - { - "name": "vault_path", - "shape": "tiles", - "tiles": [[34, 40], [34, 41], [35, 40], [35, 41]] - } - ], - "script": "missions/custom01.rhai" -} -``` - -With `missions/custom01.rhai`: - -```rhai -fn on_start(m) { - m.log("Crookfinger: We have guests."); - m.objective("Find the shard before they notice that the trap is open."); -} - -fn on_goal(m, goal) { - if goal.name == "Shards" && goal.done { - m.log("Shard found."); - m.objective("Hold out against three short waves."); - m.spawn_wave(1); - } -} - -fn on_wave(m, wave) { - if wave == 2 { - m.spawn_at("HEROES", "Knight", 67.5, 32.5, 1, 2); - } - - if wave == 3 { - m.spawn_at("HEROES", "Knight", 67.5, 38.5, 1, 2); - m.spawn_at("HEROES", "Knight", 67.5, 45.5, 1, 2); - m.warn("Last wave!"); - } -} -``` - -## 19. Rules for multiplayer and determinism - -The game simulation is deterministic in matches. Scripts must not break that determinism. - -Therefore: - -1. No local random decisions that happen on only one client. -2. Only use `m.rand_int`, `m.rand_choice` or other well-defined random functions of the mission API. -3. No timed events that depend on FPS, camera, mouse, keyboard or graphics. -4. `on_time` should only run mission logic and not write graphical effects directly into the state. -5. Change creatures, waves and goals only through the released mission API. -6. For matches, scripts can later run so that all clients have the same mission and the same seed. - -Today `on_start` runs once when a custom campaign mission starts, and Rhai itself has no access to time or randomness here (`no_time` is enabled), so the current runtime is deterministic. - -## 20. Recommended style - -### Prefer short scripts - -Good (**planned** API): - -```rhai -fn on_time(m) { - if m.time >= 300 && !m.has_flag("second_wave") { - m.set_flag("second_wave"); - m.spawn_wave(4); - } -} -``` - -Bad: - -```rhai -fn on_time(m) { - for i in 0..1000000 { - // too slow and dangerous in the simulation - } -} -``` - -The engine stops a script after 200,000 operations with an error. - -### Name flags after the mission phase - -**Planned** API. - -Good: - -```rhai -m.set_flag("phase2_started"); -m.has_flag("phase2_started"); -``` - -Less good: - -```rhai -m.set_flag("flag"); -``` - -### Counters for repeated conditions - -**Planned** API. - -Good: - -```rhai -m.bump("vaults_opened"); - -if m.counter("vaults_opened") >= 3 { - m.spawn_wave(4); -} -``` - -### Clear names for creatures and sides - -Good: - -```rhai -m.spawn_at("HEROES", "Knight", 67.5, 40.5, 1, 2); -``` - -Not possible: - -```rhai -m.spawn_at(1, 13, 67.5, 40.5, 1, 2); -``` - -Creature kinds are only accepted as names. Sides may be numbers, but names read better. - -## 21. Complete sample script for a mission - -**Planned** - uses many planned hooks and functions (`on_time`, `on_goal`, `on_rival_attacked`, `on_end`, `m.time`, flags, `m.warn`, `m.rand_choice`). - -```rhai -const MOSSGRIM = 2; - -fn start_intro(m) { - m.log("Crookfinger: This cave is not empty."); - m.objective("Find the shard and survive the waves."); -} - -fn alarm(m, key, text) { - if !m.has_flag(key) { - m.set_flag(key); - m.log(text); - m.warn(text); - } -} - -fn reinforce_from_north(m) { - m.spawn_at("HEROES", "Knight", 67.5, 28.5, 1, 2); - m.spawn_at("HEROES", "Archer", 69.5, 25.5, 1, 2); -} - -fn reinforce_from_south(m) { - m.spawn_at("HEROES", "Knight", 67.5, 52.5, 1, 2); - m.spawn_at("HEROES", "Archer", 69.5, 54.5, 1, 2); -} - -fn on_start(m) { - start_intro(m); -} - -fn on_time(m) { - if m.time >= 300 { - alarm(m, "alarm_5min", "After five minutes the guards hear something."); - - let roll = m.rand_choice(["north", "south"]); - - if roll == "north" { - reinforce_from_north(m); - } else { - reinforce_from_south(m); - } - } - - if m.time >= 900 { - alarm(m, "alarm_15min", "The second group is coming."); - m.spawn_wave(2); - } -} - -fn on_goal(m, goal) { - if goal.name == "Shards" && goal.done { - m.log("The shard is ours."); - m.objective("Hold out until the last waves come."); - m.spawn_wave(1); - } - - if goal.name == "Survive" && goal.done { - m.log("The assault is beaten."); - } -} - -fn on_rival_attacked(m, side) { - if side == global::MOSSGRIM && !m.has_flag("mossgrim_help") { - m.set_flag("mossgrim_help"); - m.log("Mossgrim hears the noise."); - m.spawn_wave(3); - } -} - -fn on_end(m, won) { - if won { - m.log("Crookfinger: Well done. The core becomes whole."); - } else { - m.log("Crookfinger: We started too early."); - } -} -``` - -Note: as in the original design, the `m.time >= 300` and `m.time >= 900` branches would run every second once reached; the reinforcements are not guarded by a flag, and `m.spawn_wave(2)` repeats. A real script should guard them like `alarm()` does. - -## 22. What the editor must be able to do later - -The level editor should be able to edit at least the following. Items marked "(done)" exist in the campaign editor today; everything else is **planned**. - -1. Mission data: - - `key` (generated when a mission is added) - - `title` (done) - - `story` - - `hints` - - `size` - - `seed` - - `gold` - - `goals` (done) - - `portals` - - `treasures` - - `waves` - - `wave_limit` - - `first_wave` - - `time_limit` - - `next` and branches (done) - -2. Campaign structure: - - Add and remove missions (done), reorder them. - - Edit mission title (done), story and hints. - - Set the campaign name (done). - - Save and load missions as JSON (done, `.dccamp`). - -3. Map editing: - - Set the size. - - Adjust the terrain. - - Edit rivers and lakes. - - Place caves. - - Place mana springs. - - Place gates. - - Place and edit fortresses. - - Set the player's starting points. - - Place trigger zones graphically: circle, rectangle, polygon, marked tiles, line/bridge. - - Name zones, set radius or tile set and add them to the script automatically. - - Optionally filter zones: player side only, knight side only, certain creatures, fire once. - -4. Script editing: - - Choose a script file or write the script in the editor (done: single-line prompt, up to 600 characters). - - Generate or update editor trigger zones in the script automatically. - - Move, delete or rename graphically placed zones in the editor and update the script. - - Syntax check with Rhai (done). - - Start a test mission directly (done: start a mission from the editor). - - Show script errors: - - unknown function (done, through Rhai when `on_start` runs) - - unknown creature (done) - - unknown zone (done) - - invalid coordinate - - invalid goal (done for the mission's `goals`, not inside scripts) - - endless loop (done, operation limit) - - unknown side name - -5. Briefing: - - Title - - Story text - - Hints - - Objectives - - Optional language/locale keys - -## 23. Error examples - -The editor should show these errors. The examples use the implemented functions where possible. - -```rhai -fn on_start(m) { - m.spawn_at("HEROES", "UnknownCreature", 48.5, 40.5, 1, 2); -} -``` - -Error: unknown creature. Implemented: rejected by the editor validation. - -```rhai -fn on_start(m) { - m.spawn_count_in_zone("no_such_zone", "Knight", 3, "HEROES", 2); -} -``` - -Error: unknown zone. Implemented: rejected by the editor validation. - -```rhai -fn on_start(m) { - m.spawn_at("HEROES", "Knight", 192.5, 40.5, 1, 2); -} -``` - -Error: coordinate outside the map. **Not reported yet**: the spawn is skipped silently. - -```rhai -fn on_start(m) { - while true { - m.spawn_wave(1); - } -} -``` - -Error: endless loop or too many repetitions. Implemented: Rhai stops after 200,000 operations and the script is rejected. - -```rhai -fn on_start(m) { - m.unknown_function(); -} -``` - -Error: unknown mission function. Implemented: Rhai reports "function not found" when `on_start` runs, so the editor rejects it. Inside hooks that are not called during validation, it would only show up in game. - -```rhai -fn on_start(m) { - m.spawn_at("HEROES", "Knight", 48, 40, 1, 2); -} -``` - -Error: wrong argument types. Implemented: Rhai reports "function not found" because `x` and `y` must be floats (`48.0`, `40.0`). - -## 24. Summary for level authors - -A mission script usually has four parts (only `on_start` is called today): - -```rhai -fn on_start(m) { - m.objective("Short objective."); -} - -fn on_time(m) { - // timed events (planned) -} - -fn on_goal(m, goal) { - // react to mission progress (planned) -} - -fn on_unit_reaches(m, unit, zone) { - // react to graphically placed zones (planned) -} -``` - -The most important tools: - -```rhai -// implemented -m.log("Text"); -m.objective("Objective"); -m.spawn_at("HEROES", "Knight", 67.5, 40.5, 1, 2); -m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2); -m.spawn_wave(3); - -// planned -m.has_flag("phase1_done"); -m.set_flag("phase1_done"); -m.counter("vaults_opened"); -m.bump("vaults_opened"); - -m.load_editor_zones(); -m.add_zone("north_gate", m.at(0.70, 0.50), 3.0); -m.destroy_walls_in_zone("north_gate"); -``` - -With these, many missions can be built: waves, boss phases, reactions, vault events, rivals, time limits, location triggers and dynamic briefings. +| Speak | Message | text – `{name}` shows the value of variable `name` | +| | Objective | text (shown as the mission's objective) | +| | Crookfinger says | text (with a sound) | +| Units | Spawn | count, kind, level, side, where (the core, the trigger unit, a zone), optional name | +| | Hero wave | count (sent at once) | +| | Remove | side, zone (removes those units) | +| World | Change ground | zone → floor, earth, rock, water, lava, gold (the core, portals, towers and the gate stay) | +| | Reveal | zone (lifts the fog of war) | +| | Show | zone (moves the camera there) | +| | Gold + / Mana + | amount (negative takes) | +| Logic | If / else | condition; blocks inside | +| | Repeat | times (0–100); blocks inside | +| | Switch trigger | trigger, on/off | +| Variables | Set | variable, value | +| | Change | variable, value to add | +| Mission | Win the mission / Lose the mission | – | + +### Values + +Every number field holds a **value**; the switch in front of it changes the value's kind: + +- numbers: *Number*, *Variable*, *Gold*, *Mana*, *Seconds played*, *Count* (creatures of a + side/kind), *Count* in a zone, *Random* (whole number between a and b, deterministic), + *Calculation* (`+ - * /`, division by 0 gives 0), +- conditions (for *If*): *Comparison* (`< <= = != >= >`), *and*, *or*, *not*, *Yes/no*, + *Trigger unit is a* (kind). + +Variables are numbers the mission remembers (saved with the game); a variable is known once some +script of the mission sets it. + +### Checking + +The editor checks while you build and marks problems red: empty texts, unknown zones, triggers or +creature kinds, variables never set, a condition where a number belongs (or the other way round). +A trigger whose zone is missing is marked in the trigger list. Problems are also listed in the +campaign file check (`CampaignDoc::validate`). + +## At run time (`src/mscript.rs`) + +- The mission's program is generated by `mscript::program` – one Rhai function per script and + trigger (`s_start`, `s_victory`, `s_defeat`, `s_tick`, `t_0`, `t_1`, ...) – and compiled once. +- Scripts read the game (`m.gold()`, `m.count(side, kind)`, `m.in_zone(zone, side, kind)`, ...) + and collect actions (`m.message`, `m.spawn_unit`, `m.terrain`, `m.win`, ...), which are applied + after the call. Variables, the random state, fired triggers and zone contents are part of + `ScriptState` and saved with the game. +- Scripts always run from the first player's side; messages reach every player of a match. +- A script error stops nothing else: it is shown once as a message and written to the test log. +- Test play shows the variables and the trigger log at the top right, and the zones on the map. + +### Rhai API (internal) + +`gold() mana() time() get(name) set(name, v) count(side, kind) in_zone(zone, side, kind) +random(a, b) div(a, b) times(n) event_is(kind) message(t) objective(t) hint(t) +spawn_unit(side, kind, count, level, place, zone, name) wave(n) add_gold(n) add_mana(n) win() lose() +reveal(zone) camera(zone) terrain(zone, kind) kill(zone, side) trigger(name, on)` – numbers are +floats; `spawn` is a reserved word in Rhai, hence `spawn_unit`. The Rhai dependency keeps the +`no_time` feature (WASM). + +## The demo + +*Open the demo* in the campaign editor: mission 1 ("The Trial of Triggers") uses all 14 trigger +kinds, all special scripts and nearly every block on a painted map with five zones (rectangle, +circle, line, tiles); mission 2 is a random map behind a branch. Play it with *Test play* and watch +the debug view.