diff --git a/docs/CAMPAIGN_EDITOR.md b/docs/CAMPAIGN_EDITOR.md index c5c1cee..d07bcd4 100644 --- a/docs/CAMPAIGN_EDITOR.md +++ b/docs/CAMPAIGN_EDITOR.md @@ -1,91 +1,109 @@ -# Kampagneneditor - Ziel, Dateiformat und Verzweigungen +# Campaign editor - goals, file format and branches -Dieses Dokument beschreibt den geplanten umfassenden Kampagneneditor für DungeonCraft. Der Editor soll vom Hauptmenü startbar sein und es ermöglichen, ganze Kampagnen zu erstellen, zu speichern, zu öffnen und an andere Spieler weiterzugeben. +This document describes the campaign editor for DungeonCraft. The editor is opened from the title menu +and lets players create, save, open and share whole campaigns. -Status: Entwurf plus erste Umsetzung. Die Kernstruktur (`CampaignDoc`, `MissionDoc`, `BranchDoc`, `.dccamp`, Menüpunkt, Editorseite, Start einzelner Missionen und Branch-Auswahl) ist implementiert; Karten-/Zonen-Editing folgt. Der Script-Basisruntime (`on_start`, `m.log`, `m.objective`, `m.spawn_at`, `m.spawn_count_in_zone`, `m.spawn_wave`) ist bereits in Rust integriert. +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. Ziel +Throughout this document, **(planned)** marks features that are designed but not implemented yet. -Der Kampagneneditor soll später Folgendes können: +## 1. Goals -- Kampagnen aus dem Hauptmenü starten. -- Neue Kampagnen erstellen. -- Bestehende Kampagnen öffnen. -- Kampagnen als Datei speichern und teilen. -- Missionen einer Kampagne bearbeiten. -- Missionsziele definieren. -- Kampagnenverzweigungen erstellen. -- Briefing, Titel, Story, Hinweise und objectives bearbeiten. -- Kartenparameter bearbeiten. -- Festungen, Tore, Flüsse, Höhlen, Manaquellen und Rivalen setzen. -- Triggerzonen grafisch festlegen. -- Missionsskripte mit Rhai bearbeiten. -- Skripteditor mit Syntax-Highlighting und Syntaxprüfung. -- Missionen testweise starten. -- Kampagnenfortschritt speichern. +The campaign editor is meant to support: -## 2. Start im Hauptmenü +- 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)* -Der Editor soll im Hauptmenü erreichbar sein, zum Beispiel: +## 2. Starting from the title menu + +The editor is an entry of the title menu. The current title menu (`Menu::draw_title` in `src/menu.rs`) +shows these entries in this order: ```text DungeonCraft -- Kampagne +- Continue (only if an autosave exists) +- Campaign +- Campaign Editor - Multiplayer -- Neues Spiel -- Spiel laden -- Einstellungen -- Konto -- Standalone herunterladen -- Bestiarium -- Kampagneneditor -- Hilfe +- New Game +- Load Game +- Settings +- Account: (only when logged in to a server) +- Download Standalone +- Bestiary +- Help ``` -Geplante Menüaktion: +Menu wiring as implemented: -```rhai -Btn::CampaignEditor -MenuAction::OpenCampaignEditor -Page::CampaignEditor +```rust +Btn::CampaignEditor // title menu button +Page::CampaignEditor // the editor page; Back returns to Page::Title ``` -Der Editor soll auch aus dem Pausemenü erreichbar sein, wenn eine Kampagne gespielt wird, aber im Spielbetrieb nicht die aktuelle Mission zerstören, ohne eine Sicherung anzulegen. +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`. -## 3. Kampagnendatei speichern und öffnen +**(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. -Kampagnendateien sollen weitergegeben werden können. Geplante Endung: +## 3. Saving and opening campaign files + +Campaign files can be passed on to other players. File extension (`CAMPAIGN_EXTENSION`): ```text .dccamp ``` -Eine `.dccamp`-Datei ist JSON. Sie enthält die vollständige Kampagne: +A `.dccamp` file is JSON (pretty-printed by `CampaignDoc::save()`). It contains the complete campaign: -- Kampagnenname -- Missionen -- Missionsziele -- Verzweigungen -- Kartenparameter -- Scripte -- Triggerzonen -- optionale Sprachtexte -- Version und Formatangaben +- 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 -Beispielhafter Export: +Example export: ```json { "format": "dungeoncraft.campaign", "version": 1, - "name": "Meine eigene Kampagne", - "author": "Spieler", + "name": "My own campaign", + "author": "Player", "missions": [ { "key": "my01", - "title": "Der erste Schacht", - "story": "Ein kleiner Posten, ein Splitter, zwei Wege.", + "title": "The first shaft", + "story": "A small outpost, one shard, two paths.", "size": [96, 80], "seed": 101010, "gold": 2500, @@ -97,13 +115,13 @@ Beispielhafter Export: { "id": "killall_path", "goal": "KillAll", - "title": "Die Wachstube zerstören", + "title": "Destroy the guardhouse", "next": "my02a" }, { "id": "loot_path", "goal": "Loot(3000)", - "title": "Die Tresore plündern", + "title": "Plunder the vaults", "next": "my02b" } ], @@ -111,48 +129,57 @@ Beispielhafter Export: { "name": "north_gate", "shape": "circle", - "at": [0.70, 0.50], + "at": [67.0, 40.0], "radius": 3.0 } ], "script": { "language": "rhai", - "source": "fn on_start(m) { m.log('Krummfinger: Es gibt zwei Wege.'); }" + "source": "fn on_start(m) { m.log('Crookfinger: There are two paths.'); }" } } ] } ``` -Geplante Menüfunktionen: +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 -Kampagne speichern ... -Kampagne öffnen ... -Kampagne herunterladen -Kampagne testweise spielen +Open ... - implemented (Btn::CampaignOpen) +Save - implemented (Btn::CampaignSave), downloads/writes .dccamp +Start - implemented (Btn::CampaignStart), test-plays the selected mission ``` -Im Browser kann das mit den vorhandenen Datei-APIs realisiert werden: +How this works per platform: -- Speichern: Datei als `.dccamp` herunterladen. -- Öffnen: Datei mit Dateiauswahl öffnen und JSON lesen. -- Herunterladen: aktuelle Kampagne als `.dccamp` exportieren. +- 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. Kampagnenstruktur +## 4. Campaign structure -Eine Kampagne ist nicht mehr nur eine lineare Liste von Missionen. Sie ist ein Missionsgraph. +A campaign is no longer just a linear list of missions. It is a mission graph. -Vereinfacht: +Simplified: ```text my01 | - |--> Ziel A erreicht: my02a - |--> Ziel B erreicht: my02b + |--> goal A reached: my02a + |--> goal B reached: my02b ``` -Komplexer: +More complex: ```text my01 @@ -171,26 +198,25 @@ my02b |--> Towers: my03b ``` -Die Kampagne kann dadurch mehrere Abschnitte, alternative Wege und Wiedervereinigungen haben. +This allows a campaign to have several sections, alternative paths and paths that join again. -## 5. Mission mit Verzweigung +## 5. Missions with branches -Eine Missionsdefinition muss später zwischen normalen Zielen und Verzweigungszielen unterscheiden. +A mission definition distinguishes between normal goals and branch goals. -Geplante Bedeutung: +Meaning: -- `goals`: optionale oder normale Ziele. -- `branches`: alternative Missionsenden. -- Eine Mission gilt als erfolgreich, wenn ein Verzweigungsziel erreicht ist. -- Das erreichte Verzweigungsziel bestimmt die nächste Mission. +- `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. -Beispiel: +Example: ```json { "key": "my01", - "title": "Die two Wege", - "story": "Die Ritter haben entweder eine Wachstube oder ein Schatzhaus. Du kannst dich entscheiden, was du zuerst schlägst.", + "title": "The two paths", + "story": "The knights have either a guardhouse or a treasury. You decide which one you strike first.", "goals": [ "Shards" ], @@ -198,76 +224,97 @@ Beispiel: { "id": "kill_path", "goal": "KillAll", - "title": "Die Wachstube zerstören", + "title": "Destroy the guardhouse", "next": "my02a", - "priority": 1 + "priority": 2 }, { "id": "loot_path", "goal": "Loot(3000)", - "title": "Die Tresore plündern", + "title": "Plunder the vaults", "next": "my02b", - "priority": 2 + "priority": 1 } ] } ``` -Regel: +Current rules (`CampaignDoc::next_mission_choice` in `src/campaign_doc.rs`): -1. `KillAll` wird zuerst erreicht: Mission endet, nächste Mission ist `my02a`. -2. `Loot(3000)` wird zuerst erreicht: Mission endet, nächste Mission ist `my02b`. -3. Wenn beide gleichzeitig erreicht werden, gewinnt die Branch mit niedrigerer `priority`. Ist keine Priorität gesetzt, gewinnt die zuerst definierte Branch. -4. Wenn eine Mission mehrere `branches` hat, darf sie nicht als vollständig gelten, solange keine Branch erreicht ist. -5. Eine Mission ohne `branches` nutzt weiterhin `next` oder die lineare Reihenfolge. +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. -## 6. Mission mit alternativem Ziel a und b +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. -Beispiel mit zwei alternativen Hauptzielen: +**(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": "Der Nachbarsstreit", - "story": "Mossgrim oder der Orden - du kannst nur einen von beiden zuerst ausschalten.", + "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": "Mossgrim zuerst ausschalten", + "title": "Take out Mossgrim first", "next": "my04a" }, { "id": "order_first", "goal": "Heart", - "title": "Das Tor des Ordens zuerst brechen", + "title": "Break the Order's gate first", "next": "my04b" } ] } ``` -Spielerlebnis: +Planned player experience: -- Der Spieler sieht im Briefing beide Wege. -- Das Interface zeigt zwei objectives: - - Mossgrims Kern zerstören. - - Das Ordenstor brechen. -- Sobald eines davon erreicht ist, wird die Mission beendet. -- Der Fortschritt speichert, welcher Weg gewählt wurde. -- Die nächste Mission hängt von diesem Weg ab. +- 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. Missionsfortschritt bei Verzweigungen +## 7. Mission progress with branches -Der Kampagnenfortschritt muss später Verzweigungen speichern. +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: -Geplanter Fortschritt: +```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": "Meine eigene Kampagne", + "campaign": "My own campaign", "done": { "my01": true, "my02b": false @@ -290,16 +337,18 @@ Geplanter Fortschritt: } ``` -Regeln: +Rules: -- Eine Mission ist nur dann abgeschlossen, wenn eine ihrer Branches erreicht wurde. -- Die erreichte Branch speichert `completed_choice`. -- Die nächste Mission wird durch `next` der erreichten Branch bestimmt. -- Andere Branches einer bereits entschiedenen Mission gelten als übersprungen. -- Carry wird von der entschiedenen Mission zur nächsten Mission mitgenommen. -- Eine wiedervereinigte Mission kann von mehreren Wegpunkten betreten werden. In diesem Fall muss der Editor entscheiden, welcher Carry-Status verwendet wird, oder jeder Pfad speichert seinen eigenen Carry. +- 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)** -Geplante Variante mit getrenntem Carry pro Branch: +**(planned)** Variant with separate carry per branch: ```json "completed_choice": { @@ -317,93 +366,119 @@ Geplante Variante mit getrenntem Carry pro Branch: } ``` -## 8. Editor-Ansicht für Kampagnenverzweigungen +## 8. Editor view for campaign branches -Im Editor soll es eine Kampagnenübersicht geben. +Implemented editor page (`Menu::draw_campaign_editor`): -Geplante Darstellung: +- 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 Die two Wege - |- KillAll -> My02a Die Wachstube +My01 The two paths + |- KillAll -> My02a The guardhouse | |- Shards -> My03a | `- Rivals -> My03b - `- Loot(3000) -> My02b Das Schatzhaus + `- Loot(3000) -> My02b The treasury |- Freed(1) -> My03c `- Towers -> My03b ``` -Editor-Funktionen: +Editor functions: -- Mission hinzufügen. -- Mission entfernen. -- Mission umbenennen. -- Mission verschieben. -- Verzweigungsziel hinzufügen. -- `goal` auswählen. -- `next` Mission auswählen. -- Branch-Titel und Beschreibung bearbeiten. -- Priorität setzen. -- Testpfad starten. -- Zyklusprüfung: Kampagnen dürfen keine Endlosschleifen ohne Spielfortschritt enthalten. -- Erreichbarkeitsprüfung: alle Missionen sollen vom Start aus erreichbar sein, außer sie sind explizit als optionale Enden oder Extra-Missionen markiert. +- 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)** -## 9. Kampagnendatei mit Verzweigungen speichern +Branch text format used by the `B` prompt (`parse_branches` / `branches_to_text`): branches are separated +by `;`, the fields of a branch by `:`: -Eine gespeicherte Kampagne muss die Verzweigungen enthalten. +```text +id:goal:next[:priority[:title]] +kill_path:KillAll:my02a:2:Destroy the guardhouse; loot_path:Loot(3000):my02b:1:Plunder the vaults +``` -Beispielmission mit zwei Folgemissionen: +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": "Die zwei Wege", + "title": "The two paths", "size": [96, 80], "seed": 101010, "gold": 2500, + "goals": [ + "Shards" + ], "branches": [ { "id": "kill_path", "goal": "KillAll", - "title": "Die Wacht zuerst ausschalten", + "title": "Take out the guard first", "next": "my02a", - "priority": 1 + "priority": 2 }, { "id": "loot_path", "goal": "Loot(3000)", - "title": "Die Tresore zuerst plündern", + "title": "Plunder the vaults first", "next": "my02b", - "priority": 2 + "priority": 1 } ], "script": { "language": "rhai", - "source": "fn on_start(m) { m.objective('Wähle einen Weg: die Wachstube oder die Tresore.'); }" + "source": "fn on_start(m) { m.objective('Choose a path: the guardhouse or the vaults.'); }" } } ``` -Wenn die Kampagne gespeichert wird, muss der Editor prüfen: +The editor checks: -- `my02a` existiert. -- `my02b` existiert. -- `goal KillAll` ist für diese Mission sinnvoll. -- `goal Loot(3000)` ist für diese Mission sinnvoll. -- Es gibt mindestens einen Startpfad. -- Die Mission endet wirklich, wenn eines der Branch-Ziele erreicht wird. +- `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)** -## 10. Geplante Rust-Datenstruktur +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. -Für die Implementierung könnte der Editor mit datengetriebenen Missionen arbeiten. +## 10. Rust data structure -Geplante Rust-Strukturen: +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, - pub version: u32, - pub name: String, + 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>, } @@ -412,20 +487,20 @@ pub struct MissionDoc { pub key: String, pub title: String, pub story: String, - pub size: (usize, usize), - pub seed: u32, - pub gold: f32, - pub portals: usize, - pub treasures: usize, + 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, - pub wave_limit: u32, - pub first_wave: f32, - pub time_limit: f32, + 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 script: Option<ScriptDoc>, pub zones: Vec<ZoneDoc>, + pub script: Option<ScriptDoc>, } pub struct BranchDoc { @@ -433,29 +508,43 @@ pub struct BranchDoc { pub goal: String, pub title: String, pub next: String, - pub priority: Option<u32>, -} - -pub struct ScriptDoc { - pub language: String, - pub source: String, + pub priority: Option<u32>, // higher wins; None counts as 0 } pub struct ZoneDoc { pub name: String, - pub shape: String, - pub at: Option<(f32, f32)>, - pub radius: Option<f32>, + 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>, + 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 } ``` -Die `String`-Ziele müssen beim Spielen in echte `Goal`-Werte übersetzt werden. +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)**. -Beispiel: +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 @@ -469,24 +558,36 @@ Beispiel: "Towers" -> Goal::Towers ``` -## 11. Verzweigungslogik im Spiel +## 11. Branch logic in the game -Die Spiellogik braucht einen Missionsabschluss mit Branch. - -Geplanter Ablauf: +Implemented flow (`src/main.rs`, main loop and `UiAction::EndNext`): ```text -Mission läuft. - Wenn ein normales Ziel erreicht wird: Fortschritt anzeigen. - Wenn ein Branch-Ziel erreicht wird: - Mission sofort beenden. - completed_choice speichern. - Carry aus aktuellem Spielstand erzeugen. - unlocked nächste Mission setzen. - Briefing oder Erfolgsmeldung anzeigen. +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. ``` -Pseudo-Logik: +**(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 { @@ -504,45 +605,51 @@ if mission_has_branches { } ``` -Bei zeitgleicher Erfüllung mehrerer Branches: +When several branches are reached at the same time: -- Sortiere Branches nach `priority`. -- Wenn keine `priority` gesetzt ist, nutze die Reihenfolge in der JSON. -- Wenn der Spieler eine Auswahl haben soll, kann der Editor `ask_on_conflict: true` aktivieren. +- 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. Beispielmission im Kampagneneditor +## 12. Example mission in the campaign editor -Beispiel für eine Mission mit grafischen Zonen, Script und Verzweigung: +**(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": "Die Nordwand oder der Tresor", - "story": "Im Norden ist eine dünne Wand. Dahinter liegt eine Ritterstaffel. Im Osten liegt ein Tresor. Du musst dich entscheiden.", + "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_wand", + "id": "north_wall", "goal": "KillAll", - "title": "Die Nordwand sprengen", + "title": "Blast the north wall", "next": "my06a", - "priority": 1 + "priority": 2 }, { "id": "east_vault", "goal": "Loot(4000)", - "title": "Den Tresor räumen", + "title": "Empty the vault", "next": "my06b", - "priority": 2 + "priority": 1 } ], "zones": [ { "name": "north_gate", "shape": "circle", - "at": [0.70, 0.50], + "at": [73.0, 48.0], "radius": 3.0 }, { @@ -553,64 +660,109 @@ Beispiel für eine Mission mit grafischen Zonen, Script und Verzweigung: ], "script": { "language": "rhai", - "source": "fn on_start(m) { m.load_editor_zones(); m.objective('Wähle: die Nordwand oder den Tresor.'); }\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('Die Nordwand fällt!');\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('Tresoralarm!');\n m.destroy_doors_in_zone('east_vault');\n m.spawn_count_in_zone('east_vault', 'Knight', 2, 1, 3);\n }\n}" + "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-Fehlerprüfungen +## 13. Editor error checks -Der Editor soll Fehler anzeigen, bevor eine Kampagne gespeichert oder gespielt wird: +The editor shows errors before a campaign is played. Implemented checks (`CampaignDoc::validate`): -- Mission `key` ist leer oder doppelt. -- `title` oder `story` fehlen. -- `size` ist zu klein oder zu groß. -- `seed` fehlt. -- `gold` ist unzulässig. -- Zielname ist unbekannt. -- `next` zeigt auf eine Mission, die nicht existiert. -- Branch zeigt auf eine Mission, die nicht existiert. -- Eine Mission hat keine erreichbare Startmission. -- Eine Mission ist nach keiner anderen Mission erreichbar. -- Branches erzeugen einen Zyklus ohne Abschluss. -- `KillAll` wird gewählt, aber es gibt keine Ritter. -- `Heart` wird gewählt, aber die Mission hat kein Tor. -- `Loot(3000)` wird gewählt, aber es gibt nicht genug Tresorgold. -- `Springs(2)` wird gewählt, aber es gibt weniger als zwei Manaquellen. -- `Freed(1)` wird gewählt, aber es gibt keine angekettete Kreatur. -- Skript enthält unbekannte Funktionen. -- Zone hat eine ungültige Form oder ungültige Koordinaten. -- Scriptquelle kann nicht als Rhai gelesen werden. +- 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. -## 14. Scripteditor mit Syntax-Highlighting +Script checks run when a script is entered with the `S` button (see section 14): -Der Editor soll ein Textfeld für Rhai enthalten. +- 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. -Geplante Editorfeatures: +**(planned)** further checks: -- Textarea im Spiel-Editor. -- Zeilennummerierung. -- Syntax-Highlighting. -- Tastatureingabe am Desktop. -- Touch: großes Textfeld oder Editor-Hilfsdialog. -- Speichern als `script.source` in der `.dccamp`-Datei. -- Testmission direkt starten. -- Fehleranzeige für ungültige Rhai-Syntax oder unbekannte Missionsfunktionen. +- `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. -Geplante Farben: +## 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 -Keyword: hellgelb -Comment: grau -String: grün -Number: orange -Mission method: hellblau -Creature kind: cyan -Zone name: violett -Error: rot +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 ``` -Beispiel: +`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; @@ -618,7 +770,7 @@ const PLAYER = 0; fn on_start(m) { m.load_editor_zones(); - m.objective("Zwei Wege: Nordwand oder Tresor."); + m.objective("Two paths: north wall or vault."); } fn on_unit_reaches(m, unit, zone) { @@ -635,129 +787,199 @@ fn on_unit_reaches(m, unit, zone) { } m.set_flag("north_opened"); - m.warn("Die Nordwand fällt!"); + m.warn("The north wall falls!"); m.destroy_walls_in_zone("north_gate"); m.spawn_count_in_zone("north_gate", "Knight", 3, HEROES, 2); } ``` -## 15. Kampagnenweitergabe +A script that works today: -Ein Spieler kann eine Kampagne als Datei weitergeben: - -```text -meine_kampagne.dccamp +```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); +} ``` -Ein anderer Spieler öffnet: +## 15. Sharing campaigns + +A player can pass a campaign on as a file: ```text -Hauptmenü -> Kampagneneditor -> Kampagne öffnen -> meine_kampagne.dccamp +my_campaign.dccamp ``` -Danach kann die Kampagne gespielt werden. - -Die Datei enthält keine Spielstände. Sie enthält nur Missionsdefinitionen, Skripte und optionale Startfortschrittsdaten. Spielstände bleiben getrennt. - -Geplante Dateitypen: +Another player opens it: ```text -.dccamp - Kampagnendefinition -.dcsave - Spielstand +Title menu -> Campaign Editor -> Open -> my_campaign.dccamp ``` -Ein Spielstand kann später auch eine aktive Kampagne referenzieren, aber die Kampagnendatei bleibt veränderbar und teilbar. +After that the campaign can be played (*Start* on the first mission). -## 16. Empfohlene Implementierungsreihenfolge +The file contains no savegames. It only contains mission definitions and scripts; optional starting +progress data is **(planned)**. Savegames stay separate. -Die Umsetzung sollte in Etappen erfolgen. +File types: -### Etappe A: Datenmodell +```text +.dccamp - campaign definition +.dcsave - savegame +``` -- `CampaignDoc` -- `MissionDoc` -- `BranchDoc` -- `ZoneDoc` -- `ScriptDoc` -- JSON-Laden und Speichern -- Tests für Beispielkampagnen +**(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. -### Etappe B: Kampagnenstart im Menü +## 16. Implementation order -- Hauptmenüpunkt `Kampagneneditor`. -- Editorseite mit Kampagnenliste. -- Kampagne neu erstellen. -- Kampagne öffnen. -- Kampagne speichern. +The implementation proceeds in stages. -### Etappe C: Missionseditor +### Stage A: data model - done -- Missionsliste innerhalb einer Kampagne. -- Mission hinzufügen, entfernen, auswählen. -- Missionsdaten bearbeiten. -- Branches bearbeiten. -- Ziele bearbeiten. -- Kartenparameter bearbeiten. +- `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`)* -### Etappe D: Verzweigungen spielen +### Stage B: campaign editor in the menu - done -- Mission endet bei Branch-Erfolg. -- Folgemission wird entsperrt. -- Progress speichert `completed_choice`. -- Carry übernimmt den gewählten Pfad. -- Kampagnenübersicht zeigt verzweigten Fortschritt. +- 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)* -### Etappe E: Zoneneditor +### Stage C: mission editor - partial -- Kreis zeichnen. -- Rechteck zeichnen. -- Polygon zeichnen. -- Tiles markieren. -- Linien setzen. -- Zonen automatisch ins Skript übernehmen. +- 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)** -### Etappe F: Scripteditor +### Stage D: playing branches - partial -- Texteditor mit Syntax-Highlighting. -- Rhai-Syntaxprüfung. -- Mission-API-Hilfe. -- Testmission starten. +- 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)** -### Etappe G: Kampagnentests +### Stage E: zone editor - planned -- Beispielkampagne bauen. -- Branch-Kampagne bauen. -- `.dccamp` exportieren. -- `.dccamp` importieren. -- Fortschritt speichern und laden. +- 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)** -## 16a. Umgesetzter Stand +Zones written in the JSON (circle, rectangle, line, tiles) already work for spawning and the debug overlay. -- `src/campaign_doc.rs`: `CampaignDoc`, `MissionDoc`, `BranchDoc`, `ZoneDoc`, `ScriptDoc`, `CampaignProgress`. -- JSON-Dateien `.dccamp` können geladen, validiert, gespeichert und mit `CampaignDoc::file_name()` benannt werden. -- Hauptmenü: `Btn::CampaignEditor` führt auf `Page::CampaignEditor`. -- Editorseite: Kampagnenname, Missionsliste, Auswahl, Neu, Add, Delete, Open, Save, Start. -- Feldbearbeitung im ersten Schritt: Titel, Ziele, nächste Mission, Branches und Rhai-Skriptquelle über Browser-/Desktop-Eingabeaufforderungen. -- Missionsstart: `campaign::mission_def_from_doc()` und `campaign::start_mission_doc()` können eine einzelne Editormission spielen. -- Branch-Auswahl: `CampaignDoc::next_mission_choice()` wählt nach Missionsende die nächste Mission; `CampaignProgress` speichert `done` und `completed_choice`. -- Script-Basisruntime: `src/script.rs` führt `on_start(m)` in Rhai aus. Unterstützt sind vorerst `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)` und `m.spawn_wave(count)`; Seiten können als Zahlen (`0` bis `4`) oder Namen (`PLAYER`, `HEROES`, `MOSSGRIM`, `NIGHTWELL`, `RIVAL3`) geschrieben werden. Meldungen, objective, Spawns und eine sofortige Missionswelle werden in `Game` angewendet. -- Script-Editor (einfach): `Btn::CampaignEditScript` speichert eine Rhai-Quelle in `MissionDoc.script`; die Quelle wird vor dem Speichern mit `script::run_start()` und `validate_script_source()` geprüft. Syntaxfehler, unbekannte Kreaturnamen und unbekannte Zonennamen werden abgelehnt. -- Debug-Anzeige: Einstellungen haben eigene Debug-Optionen für FPS, Framezeit, Ping, Triggerzonen, Debug-Befehlsmarker und Pfadlinien. `guard`, `attack` und `flag`/`gather` zeigen feste Befehlsmarker; alle übrigen Befehlsmarker sind debugpflichtig. -- Noch offen: grafischer Zoneneditor, ausgebauter Rhai-Skripteditor mit Textfeld und Syntax-Highlighting, weitere Mission-Hooks (`on_unit_reaches`, `on_unit_crosses`, Flags, Zonen, zeitgesteuerte Hooks), Zonen-Spawn-API, vollständige Kartenparameter, Tragen von Einheiten/Gold durch Branches. +### Stage F: script editor - partial -## 17. Kurzfassung +- 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)* -Ein Kampagneneditor für ganze Kampagnen braucht nicht nur Missionstexte, sondern einen Missionsgraphen. +### Stage G: campaign tests - partial -Kernidee: +- 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 - -> Ziel A erreicht: Mission my02a - -> Ziel B erreicht: Mission my02b + -> goal A reached: mission my02a + -> goal B reached: mission my02b ``` -Die Kampagne wird als `.dccamp` gespeichert: +The campaign is saved as `.dccamp`: ```json "branches": [ @@ -766,4 +988,5 @@ Die Kampagne wird als `.dccamp` gespeichert: ] ``` -Der Editor muss diese Verzweigungen anzeigen, prüfen und speichern können. Der Spieler sieht dann alternative objectives und die Kampagne führt je nach erreichten Zielen zu unterschiedlichen Folgemissionen. +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. diff --git a/docs/MISSION_SCRIPTING.md b/docs/MISSION_SCRIPTING.md index 92e95cc..f1d2d48 100644 --- a/docs/MISSION_SCRIPTING.md +++ b/docs/MISSION_SCRIPTING.md @@ -1,28 +1,76 @@ -# Mission-Scripting mit Rhai +# Mission scripting with Rhai -Diese Anleitung beschreibt die geplante Scriptsprache für Missionen und den Level-/Kampagneneditor. Die Sprache ist **Rhai**, weil sie direkt in Rust läuft, WASM-tauglich ist, eine einfache Schreibweise hat und gut sandboxbar ist. +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. -Status: Entwurf plus erste Umsetzung. Die Beispiele zeigen die geplante API. Implementiert ist derzeit der Basis-Hook `on_start(m)` in `src/script.rs`, mit `m.log(text)`, `m.objective(text)` und direktem Spawn über `m.spawn_at(side, kind, x, y, count, level)`. `side` akzeptiert Zahlen (`0` bis `4`) oder Namen wie `"PLAYER"`, `"HEROES"`, `"MOSSGRIM"`, `"NIGHTWELL"`, `"RIVAL3"`. Zonen, Flags, Einheiten-Ereignisse, zeitgesteuerte Hooks und vollständige Missionsfelder sind noch nicht aktiv. Der geplante Name `m.spawn` ist in Rhai reserviert; für jetzt ist `m.spawn_at` die aktive Funktion. +## Status -## 1. Wofür Missionsscripting da ist +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: -Missionsscripting erlaubt Missionen, die nicht nur aus festen Daten bestehen: +**Runtime** (`src/script.rs`, applied in `start_custom_script()` in `src/main.rs`) -- Zeitabläufe: nach 300 Sekunden greifen Verstärkungen an. -- Reaktionen: wenn der Spieler einen Tresor plündert, erscheint ein Paladin. -- Phasen: erst Wellen, dann Festung, dann Boss. -- Zufall mit Kontrolle: eine patrouillierende Gruppe nimmt eine von zwei Wegen. -- Dynamische Briefings und objectives. -- Rivalen, Höhlen, Splitter, Manaquellen und Tore können auf den Spielfortschritt reagieren. -- Kampagnenmissionen können später mit eigener Geschichte, eigenen Zielen und eigener Logik bearbeitet werden. +- 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`. -Wichtig: Skripte sollen Missionen verändern, aber nicht das ganze Spielprogramm ersetzen. Sie werden daher in einer abgesicherten Umgebung ausgeführt. +**Actions on `m`** -## 2. Sprache: Rhai +| Call | Effect | +|---|---| +| `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 | -Die Missionsskripte werden in Rhai geschrieben. +Argument details: -Beispiel: +- `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)). + +**Validation** (`validate_script_source()` in `src/main.rs`) + +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; @@ -30,100 +78,121 @@ const MOSSGRIM = 2; const NIGHTWELL = 3; fn on_start(m) { - m.log("Krummfinger hat einen Plan."); - m.objective("Finde den Splitter, bevor die Wachen Alarm schlagen."); + m.log("Crookfinger has a plan."); + m.objective("Find the shard before the guards raise the alarm."); } ``` -Rhai ist absichtlich einfach gehalten: +Rhai is deliberately simple: -- Variablen mit `let`. -- Konstanten mit `const`. -- Funktionen mit `fn`. -- `if`, `for`, `while`, Booleans, Zahlen und Strings. -- Arrays mit `[...]`. -- Maps mit `#{...}`. -- Keine Dateien, keine Netzwerkzugriffe, kein direkter Zugriff auf Rust-Codespeicher. +- 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. -## 3. Wo ein Missionsskript liegt +Constants declared at the top are only visible inside functions as `global::NAME`: -Eine Mission besteht künftig aus Daten und optional einem Skript. +```rhai +const HEROES = 1; -Beispiel als Missionsdefinition: +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": "Die erste Falle", - "story": "Die Wachen glauben, der alte Schacht sei sicher. Sie irren sich.", + "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": "missions/custom01.rhai" + "script": { + "language": "rhai", + "source": "fn on_start(m) { m.objective(\"Find the shard.\"); }" + } } ``` -Im Editor könnte das Skript direkt in einer Textbox oder in einer Datei `missions/custom01.rhai` bearbeitet werden. +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"`). -Der Kampagneneditor kann später diese Struktur vollständig speichern: +The campaign file stores the whole structure: ```json { - "campaign": "Eigene Kampagne", + "format": "dungeoncraft.campaign", + "version": 1, + "name": "My campaign", + "author": "", "missions": [ { "key": "custom01", - "title": "Die erste Falle", - "story": "Kurztext für das Briefing.", + "title": "The first trap", + "story": "Short text for the briefing.", "goals": ["Shards"], - "script": "missions/custom01.rhai" + "script": { "language": "rhai", "source": "fn on_start(m) { m.objective(\"Find the shard.\"); }" } }, { "key": "custom02", - "title": "Die zweite Welle", - "story": "Nach dem Splitter kommt der Orden zurück.", + "title": "The second wave", + "story": "After the shard the Order comes back.", "goals": ["Survive(4)"], - "script": "missions/custom02.rhai" + "script": { "language": "rhai", "source": "fn on_start(m) { m.spawn_wave(2); }" } } ] } ``` -## 4. Das Missionsobjekt `m` +See `docs/CAMPAIGN_EDITOR.md` for the full campaign format. -Alle Ereignisse bekommen ein Missionsobjekt `m` übergeben. Es stellt die Mission, den Spielstand und die erlaubten Aktionen bereit. +## 4. The mission object `m` -### Lesbare Eigenschaften +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). -| Feld | Bedeutung | +### Readable properties + +**Planned** - none of these properties exist yet; reading them is a script error. + +| Field | Meaning | |---|---| -| `m.time` | verstrichene Missionszeit in Sekunden | -| `m.map_w` | Kartenbreite in Tiles | -| `m.map_h` | Kartenhöhe in Tiles | -| `m.gold` | Gold des gerade aktiven Meisters | -| `m.mana` | Mana des gerade aktiven Meisters | -| `m.loot` | bereits erbeutetes Tresorgold | -| `m.shards_taken` | genommene Seelenkernsplitter | -| `m.springs` | eingenommene Manaquellen | -| `m.freed` | befreite angekettete Kreaturen | -| `m.wave` | aktuelle bzw. letzte Nummer der Heldwellen | -| `m.time_limit` | Zeitlimit in Sekunden, `0` bedeutet kein Limit | -| `m.wave_limit` | Wellenlimit, `0` bedeutet endlos | -| `m.gate_hp` | Lebenspunkte des Ordens-Tores | -| `m.state` | `0` läuft, `1` Sieg, `2` Niederlage | +| `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 | -### Seiten +### Sides -| Konstante | Bedeutung | -|---|---| -| `PLAYER` oder `0` | roter Meister, normalerweise der Spieler | -| `HEROES` oder `1` | der Orden der Ritter | -| `MOSSGRIM` oder `2` | grüner Rivale Mossgrim | -| `NIGHTWELL` oder `3` | blauer Rivale Lady Nightwell | -| `RIVAL3` oder `4` | dritter Rivale, gelb | +Implemented for `spawn_at` and `spawn_count_in_zone`: -Empfohlen, benannte Konstanten zu benutzen: +| Name | Number | Meaning | +|---|---|---| +| `"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; @@ -131,9 +200,9 @@ const MOSSGRIM = 2; const NIGHTWELL = 3; ``` -### Kreaturen +### Creatures -Kreaturennamen als Zeichenketten, passend zu den internen Namen: +Creature names as strings, matching the internal names (`parse_spawn_kind()` in `src/script.rs`, case-insensitive): ```rhai "Worker" @@ -160,15 +229,19 @@ Kreaturennamen als Zeichenketten, passend zu den internen Namen: "BattleMage" ``` -Beispiel: +Any other name is rejected by the editor validation. + +Example: ```rhai -m.spawn("Knight", 2, m.at(0.72, 0.48), HEROES); +m.spawn_at("HEROES", "Knight", 69.5, 38.5, 1, 2); ``` -### Ziele +### Goals -Die Missionsziele können als Zielobjekte behandelt werden: +**Planned** - goal objects and `on_goal` do not exist yet. + +Mission goals can be handled as goal objects: ```rhai goal.name @@ -176,7 +249,7 @@ goal.count goal.done ``` -Namen: +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 @@ -190,28 +263,34 @@ Springs Freed ``` -Beispiel: +Example: ```rhai if goal.name == "Shards" && goal.done { - m.objective("Zweiter Abschnitt: Die Festung."); + m.objective("Second stage: the fortress."); } ``` -## 5. Erlaubte Aktionen +## 5. Allowed actions -Die erlaubten Funktionen sind bewusst klein und sicher gehalten. +The allowed functions are deliberately few and safe. -### Text und UI +### Text and UI + +Implemented: `m.log` and `m.objective`. **Planned**: `m.hint` and `m.warn`. ```rhai -m.log("Nachricht im Log."); -m.hint("Hinweis für den Spieler."); -m.warn("Warnung mit Alarm-Ton."); -m.objective("Aktuelles Ziel im Interface."); +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."); ``` -### Effekte +`m.objective` currently shows the text as a message (`Objective: ...`), not in a separate objective panel. + +### Effects + +**Planned.** ```rhai m.play("alarm"); @@ -219,7 +298,7 @@ m.flash("danger"); m.flash("treasure"); ``` -Geplante Effekt-Namen: +Planned effect names: ```rhai alarm @@ -230,7 +309,9 @@ level_up heal ``` -### Variablen, Flags und Zähler +### Variables, flags and counters + +**Planned.** ```rhai m.set("phase", 1); @@ -245,35 +326,47 @@ m.bump("vaults_opened"); m.counter("vaults_opened"); ``` -Regel: Jede Aktion, die nur einmal passieren soll, wird mit einem Flag oder Zähler geschützt. +Rule: every action that should happen only once is guarded by a flag or a counter. -### Positionen +### Positions -Koordinaten im Editor werden am besten als Bruchteile der Karte angegeben: +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) ``` -Das bedeutet: 70 Prozent der Kartenbreite, 50 Prozent der Kartenhöhe. Der Editor rechnet das automatisch in Tile-Koordinaten um. +This means 70 percent of the map width and 50 percent of the map height. The game converts this to tile coordinates. -Weitere geplante Positionshilfen: +More planned position helpers: ```rhai m.map_center() m.fort_pos(0) +m.cave_pos(0) m.gate_pos() m.core_pos(0) -m.cave_pos(0) ``` -### Triggerzonen, markierte Kacheln und Linien +To use these positions for spawning, a planned `spawn_at` variant takes a position instead of `x`, `y`: -Location-Trigger sind benannte Orte, Flächen oder Wege in der Mission. Das Skript kann reagieren, wenn eine Kreatur diese Stelle erreicht, eine Linie überquert, in einer Zone spawnen soll oder Zerstörungen in der Zone auslöst. +```rhai +m.spawn_at("HEROES", "Knight", m.at(0.72, 0.48), 1, 2); // planned +``` -Zonen können direkt im Skript definiert oder grafisch im Level-/Kampagneneditor festgelegt werden. Wenn sie im Editor gezeichnet werden, übernimmt der Editor sie automatisch in die Mission und stellt sie dem Skript bereit. +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). -#### Zonen im Skript definieren +### 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) { @@ -283,7 +376,7 @@ fn on_start(m) { } ``` -Flächen aus markierten Kacheln: +Areas made of marked tiles: ```rhai fn on_start(m) { @@ -295,7 +388,7 @@ fn on_start(m) { } ``` -Rechtecke und Linien: +Rectangles and lines: ```rhai fn on_start(m) { @@ -304,20 +397,22 @@ fn on_start(m) { } ``` -#### Zonen grafisch im Editor festlegen +#### Drawing zones in the editor -Der Level-/Kampagneneditor kann Zonen grafisch setzen: +**Planned** - the campaign editor cannot draw zones yet; zones are written into the `.dccamp` file by hand. -- Kreis: Zentrum und Radius ziehen. -- Rechteck: zwei Ecken ziehen. -- Polygon: Punkte anklicken. -- Markierte Kacheln: einzelne Tiles bemalen oder eine Fläche malen. -- Linie: Brücke, Weg, Patrouillenpfad oder Grenzlinie ziehen. -- Optionaler Filter: Seite, Kreaturentyp, einmalig, nur Spielerkreaturen, nur Gegnerkreaturen. +The level/campaign editor can place zones graphically: -Die grafisch gesetzten Zonen werden in den Missionsdaten gespeichert und automatisch für das Skript verfügbar gemacht. Das Skript muss sie nicht von Hand definieren. Es kann aber jederzeit zusätzliche Zonen ergänzen. +- 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. -Beispielhafter Skriptaufruf: +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) { @@ -325,7 +420,7 @@ fn on_start(m) { } ``` -Nach `m.load_editor_zones()` stehen alle im Editor gezeichneten Zonen im Skript zur Verfügung. Wer zusätzliche Trigger braucht, kann sie danach ergänzen: +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) { @@ -335,7 +430,7 @@ fn on_start(m) { } ``` -Der Editor kann den generierten Anteil des Skripts auch sichtbar machen: +The editor can also show the generated part of the script: ```rhai // --- editor-generated zones: normally not edited manually --- @@ -345,24 +440,32 @@ fn editor_zones(m) { } ``` -Dann ruft das Missionsskript einfach `editor_zones(m)` auf. +The mission script then simply calls `editor_zones(m)`. -Zonen können auch direkt in der Missionsdefinition stehen: +#### 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": [0.70, 0.45], + "at": [67, 36], "radius": 2.0 }, { "name": "vault_approach", "shape": "circle", - "at": [0.82, 0.50], + "at": [78, 40], "radius": 3.0 }, + { + "name": "guard_room", + "shape": "rect", + "from": [67, 32], + "to": [77, 48] + }, { "name": "vault_path", "shape": "tiles", @@ -371,33 +474,42 @@ Zonen können auch direkt in der Missionsdefinition stehen: { "name": "bridge_path", "shape": "line", - "from": [0.60, 0.50], - "to": [0.72, 0.50], + "from": [58, 40], + "to": [69, 40], "width": 1.0 } ] ``` -#### Zonenaktionen im Skript +| Shape | Fields | Spawn positions of `spawn_count_in_zone` | +|---|---|---| +| `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 | -Sobald eine Zone definiert ist, kann das Skript in dieser Zone Aktionen auslösen. Direkt umgesetzt sind bereits `m.spawn_at` und `m.spawn_count_in_zone`; `m.spawn_in_zone` folgt später: +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); -m.spawn_count_in_zone("north_gate", "Knight", 3, HEROES, 2); -m.spawn_count_in_zone("north_gate", "Archer", 3, HEROES, 1); +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"); -m.destroy_walls_at(m.at(0.72, 0.50), 2.0); +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"); -m.destroy_towers_in_zone("north_gate"); +m.destroy_doors_in_zone("vault_path"); // planned +m.destroy_towers_in_zone("north_gate"); // planned -m.log("Die Zone ist offen."); -m.warn("Die Ritter kommen durch die Öffnung!"); +m.log("The zone is open."); +m.warn("The knights are coming through the gap!"); // planned ``` -Beispiel: Eine Kreatur erreicht eine grafisch gesetzte Zone, die Wand fällt, und dahinter spawnen Gegner. +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; @@ -405,7 +517,7 @@ const PLAYER = 0; fn on_start(m) { m.load_editor_zones(); - m.log("Eine markierte Zone ist im Editor gesetzt worden."); + m.log("A marked zone has been placed in the editor."); } fn on_unit_reaches(m, unit, zone) { @@ -413,7 +525,7 @@ fn on_unit_reaches(m, unit, zone) { return; } - if unit.side != PLAYER { + if unit.side != global::PLAYER { return; } @@ -422,33 +534,27 @@ fn on_unit_reaches(m, unit, zone) { } m.set_flag("north_gate_opened"); - m.log("Eine Kreatur hat das Nordtor erreicht."); - m.warn("Hinter der Wand warten Ritter!"); + 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, HEROES, 2); - m.spawn("Archer", 3, m.fort_pos(0), HEROES); + m.spawn_count_in_zone("north_gate", "Knight", 3, global::HEROES, 2); + m.spawn_at(global::HEROES, "Archer", m.fort_pos(0), 1, 3); } ``` -Diese Funktionen dürfen nicht willkürlich beliebige Tiles bearbeiten. Sie arbeiten nur über die Missions-API und sind deterministisch. +These functions must not edit arbitrary tiles. They only work through the mission API and are deterministic. -### Kreaturen und Wellen erscheinen lassen +### Spawning creatures and waves -Geplant: - -```rhai -m.spawn(kind, level, at, side); -``` - -Umgesetzt ist für jetzt, weil `spawn` in Rhai reserviert ist: +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); ``` -Beispiel: +Example: ```rhai m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2); @@ -456,32 +562,34 @@ m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2); ``` ```rhai -m.spawn("Knight", 2, m.at(0.72, 0.48), HEROES); +m.spawn_at("HEROES", "Knight", 69.5, 38.5, 1, 2); ``` -Gruppen über mehrere Aufrufe: +Groups through several calls: ```rhai -m.spawn("Knight", 2, m.at(0.72, 0.45), HEROES); -m.spawn("Archer", 2, m.at(0.74, 0.55), HEROES); +m.spawn_at("HEROES", "Knight", 69.5, 36.5, 1, 2); +m.spawn_at("HEROES", "Archer", 71.5, 44.5, 1, 2); ``` -Wellen: +Creatures of side `1` are spawned as heroes; all other sides spawn creatures for that master. -Umgesetzt ist für jetzt nur `m.spawn_wave(count)`. Es setzt die Missionswellen auf `true`, erhöht das Wellenlimit um die gewünschte Anzahl und startet die nächste Welle sofort: +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); ``` -Geplant, aber noch nicht umgesetzt: +**Planned**: ```rhai m.set_wave_limit(8); m.set_first_wave(180); ``` -Ziele und Limits ändern: +Changing goals and limits (**planned**): ```rhai m.set_time_limit(1200); @@ -489,51 +597,53 @@ m.add_time_limit(300); m.set_wave_limit(10); ``` -### Zufall +### Randomness -Nur erlaubte Zufallsfunktionen benutzen: +**Planned.** Only use the allowed random functions: ```rhai m.rand_int(1, 10) m.rand_choice(["north", "south"]) ``` -Beispiel: +Example: ```rhai let path = m.rand_choice(["north", "south"]); m.set("patrol_path", path); ``` -## 6. Ereignis-Hooks +## 6. Event hooks -Ein Missionsskript kann verschiedene Ereignisse definieren. Nicht jedes Ereignis muss vorkommen. +A mission script can define several hooks. Not every hook has to be present. -| Hook | Wann | -|---|---| -| `on_start(m)` | Missionsstart | -| `on_time(m)` | regelmäßig während der Mission, empfohlen: 1x pro Sekunde | -| `on_turn(m, turn)` | in Matches, wenn eine Server-Runde ankommt | -| `on_wave_start(m)` | vor einer neuen Welle | -| `on_wave(m, wave)` | nach dem Start einer Welle | -| `on_vault_claimed(m, vault_index, value)` | wenn ein Rittertresor erobert wurde | -| `on_shard_taken(m, count)` | wenn ein Splitter genommen wurde | -| `on_tower_destroyed(m, kind)` | wenn ein Turm zerstört wurde | -| `on_fort_alarm(m, fort_index)` | wenn eine Festung Alarm schlägt | -| `on_rival_attacked(m, side)` | wenn ein Rivale direkt angegriffen wird | -| `on_rival_dead(m, side)` | wenn ein Rivalenkern zerstört ist | -| `on_cave_claimed(m, cave_index, chained)` | wenn eine Höhle eingenommen wird | -| `on_freed(m, kind, cave_index)` | wenn eine angekettete Kreatur befreit wird | -| `on_gate_damage(m, damage)` | wenn das Ordenstor getroffen wird | -| `on_unit_reaches(m, unit, zone)` | wenn eine Kreatur eine Triggerzone oder ein Ziel erreicht | -| `on_unit_crosses(m, unit, line)` | wenn eine Kreatur eine Triggerlinie überquert | -| `on_branch(m, branch)` | wenn ein Verzweigungsziel erreicht wurde und die nächste Mission feststeht | -| `on_goal(m, goal)` | wenn sich ein Missionsziel deutlich ändert oder erreicht wird | -| `on_end(m, won)` | Missionsende | +Implemented: only `on_start(m)`. All other hooks are **planned**; defining them today is allowed but they are never called. -### Das `unit`-Objekt in Location-Triggern +| 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 | -In `on_unit_reaches` und `on_unit_crosses` bekommt das Skript ein kleines `unit`-Objekt: +### 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 @@ -545,36 +655,36 @@ unit.x unit.y ``` -Beispiel: +Example: ```rhai -if unit.kind == "Ogre" && unit.side == PLAYER { - m.log("Ein Oger hat die Zone erreicht."); +if unit.kind == "Ogre" && unit.side == global::PLAYER { + m.log("An ogre has reached the zone."); } ``` -Die Zone `zone` ist der Name der grafisch gesetzten oder im Skript definierten Zone: +`zone` is the name of the zone placed in the editor or defined in the script: ```rhai if zone == "north_gate" { - // nur auf diese Zone reagieren + // only react to this zone } ``` -Triggerzonen sollten immer mit Flags oder Zählern abgesichert werden, damit dieselbe Aktion nicht mehrfach ausgelöst wird. +Trigger zones should always be guarded by flags or counters so that the same action does not fire several times. -### Missionsverzweigungen im Skript +### Mission branches in the script -Wenn eine Mission Verzweigungsziele hat, kann das Skript auf eine erreichte Branch reagieren: +**Planned.** If a mission has branch goals, the script can react to a reached branch: ```rhai fn on_branch(m, branch) { - m.log("Zwei Wege: " + branch.id); - m.objective("Die Story führt weiter auf dem Weg " + branch.id); + m.log("Two paths: " + branch.id); + m.objective("The story continues on the path " + branch.id); } ``` -Ein Branch-Objekt kann später diese Felder haben: +A branch object can later have these fields (the same as a branch in the mission data): ```rhai branch.id @@ -583,29 +693,29 @@ branch.goal branch.next ``` -Beispiel für zwei alternative Wege: +Example for two alternative paths: ```rhai fn on_branch(m, branch) { if branch.goal == "KillAll" { - m.log("Die Wachstube ist zerstört."); - m.objective("Der Weg führt zu den Patrouillenpfaden."); + m.log("The guardroom is destroyed."); + m.objective("The path leads to the patrol routes."); } if branch.goal == "Loot" { - m.log("Die Tresore sind geplündert."); - m.objective("Der Weg führt zu den Goldkammern."); + m.log("The vaults are looted."); + m.objective("The path leads to the gold chambers."); } } ``` -Weitere Informationen dazu stehen in `docs/CAMPAIGN_EDITOR.md`. +The branches themselves (choosing the next mission by goal) are implemented in the campaign data; see `docs/CAMPAIGN_EDITOR.md`. -## 7. Grundregel: Alles muss wiederholbar sein +## 7. Basic rule: everything must be repeatable -Ereignisse können mehrfach ausgelöst werden. Ein Skript soll deshalb idiomatisch Flags oder Zähler benutzen. +**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. -Beispiel: Ein Alarm soll genau einmal ausgelöst werden. +Example: an alarm should fire exactly once. ```rhai fn on_fort_alarm(m, fort_index) { @@ -613,94 +723,96 @@ fn on_fort_alarm(m, fort_index) { if !m.has_flag(key) { m.set_flag(key); - m.log("Die Wache hat Alarm geschlagen!"); + m.log("The guard has raised the alarm!"); m.play("alarm"); } } ``` -Hilfsmuster für einmalige Aktionen: +Helper pattern for one-time actions: ```rhai fn once(m, key, action) { if !m.has_flag(key) { m.set_flag(key); - action(); + action.call(); } } ``` -Verwendung: +Usage: ```rhai fn on_start(m) { once(m, "intro", || { - m.log("Krummfinger: Wir haben Gäste."); - m.objective("Erkunde den Schacht."); + m.log("Crookfinger: We have guests."); + m.objective("Explore the shaft."); }); } ``` -## 8. Beispiel 1: Minimales Startskript +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 -const HEROES = 1; - fn on_start(m) { - m.log("Krummfinger: Ein kleiner Posten, aber vielleicht ein Splitter."); - m.objective("Finde den Splitter."); + 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("Splitter gesichert."); - m.objective("Der Weg zur Zitadelle ist offen."); + m.log("Shard secured."); + m.objective("The way to the citadel is open."); } } ``` -## 9. Beispiel 2: Zeitgesteuerte Verstärkung +## 9. Example 2: Timed reinforcements -Diese Mission lässt nach fünf Minuten Ritter aus dem Osten erscheinen. Nach zehn Minuten kommt eine zweite Gruppe. +**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 -const HEROES = 1; - fn on_time(m) { if m.time >= 300 && !m.has_flag("wave_5min") { m.set_flag("wave_5min"); - m.log("Verstärkung aus dem Osten."); - m.spawn("Knight", 2, m.at(0.70, 0.48), HEROES); - m.spawn("Archer", 2, m.at(0.70, 0.55), HEROES); + 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("Jetzt kommen sie ernsthaft."); - m.spawn("Knight", 3, m.at(0.74, 0.45), HEROES); - m.spawn("Knight", 3, m.at(0.74, 0.60), HEROES); - m.warn("Zweite Welle!"); + 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. Beispiel 3: Drei Phasen einer Mission +## 10. Example 3: Three phases of a mission -Diese Mission hat drei Abschnitte. Jeder Abschnitt wird genau einmal ausgelöst. +**Planned** (`on_time`, `m.time`, flags, `m.warn`). + +This mission has three stages. Each stage fires exactly once. ```rhai -const HEROES = 1; - fn phase_name(m, phase) { if phase == 1 { - return "Erste Phase"; + return "First phase"; } if phase == 2 { - return "Zweite Phase"; + return "Second phase"; } - return "Dritte Phase"; + return "Third phase"; } fn on_time(m) { @@ -722,71 +834,71 @@ fn on_time(m) { if phase == 1 { m.spawn_wave(3); - m.objective("Halte die erste kleine Welle."); + m.objective("Hold off the first small wave."); } if phase == 2 { m.spawn_wave(5); - m.spawn("Paladin", 2, m.at(0.80, 0.50), HEROES); - m.objective("Schlage die Verstärkung zurück."); + 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("Der Orden kommt mit allem, was er hat."); - m.objective("Überlebe den letzten Ansturm."); + m.warn("The Order comes with everything it has."); + m.objective("Survive the last assault."); } } } ``` -## 11. Beispiel 4: Tresor löst Boss aus +## 11. Example 4: Vault triggers a boss -Wenn der Spieler genug Tresore plündert, erscheint ein starker Gegner. +**Planned** (`on_vault_claimed`, counters, flags, `m.warn`). + +When the player loots enough vaults, a strong enemy appears. ```rhai -const HEROES = 1; - fn on_vault_claimed(m, vault_index, value) { - m.log("Tresor " + vault_index.to_string() + " ist offen: " + value.to_string()); + 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("Der Wächter wird gerufen."); - m.spawn("Paladin", 4, m.at(0.82, 0.50), HEROES); - m.warn("Ein Paladin nähert sich."); + 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("Der Orden schickt alles, was er übrig hat."); + m.log("The Order sends everything it has left."); m.spawn_wave(6); } } ``` -## 12. Beispiel 5: Bosskampf mit mehreren Phasen +## 12. Example 5: Boss fight with several phases -Ein Paladin wird nach und nach von weiteren Rittern unterstützt. +**Planned** (`on_tower_destroyed`, counters, flags, `m.warn`). + +A paladin is supported by more and more knights. ```rhai -const HEROES = 1; - fn boss_phase(m, phase) { if phase == 1 { - m.spawn("Knight", 3, m.at(0.76, 0.42), HEROES); - m.spawn("Knight", 3, m.at(0.76, 0.58), HEROES); - m.log("Der Paladin wird von Rittern begleitet."); + 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("BattleMage", 3, m.at(0.82, 0.50), HEROES); - m.warn("Ein Schlachtenmagier betritt das Feld."); + m.spawn_at("HEROES", "BattleMage", 79.5, 40.5, 1, 3); + m.warn("A battle mage enters the field."); } if phase == 3 { - m.spawn("Giant", 3, m.at(0.78, 0.50), HEROES); - m.log("Ein Riese zertrümmert die ersten Fallen."); + m.spawn_at("HEROES", "Giant", 75.5, 40.5, 1, 3); + m.log("A giant smashes the first traps."); } } @@ -817,9 +929,11 @@ fn on_tower_destroyed(m, kind) { } ``` -## 13. Beispiel 6: Rivalen reagieren lassen +## 13. Example 6: Letting rivals react -Ein Rivale greift zurück, sobald der Spieler zu aggressiv wird. +**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; @@ -830,69 +944,69 @@ fn on_rival_attacked(m, side) { if m.counter("rival_pressure") >= 3 && !m.has_flag("mossgrim_retaliation") { m.set_flag("mossgrim_retaliation"); - m.log("Mossgrim spürt deinen Angriff."); + 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 mischt sich ein."); - m.spawn("Owl", 3, m.at(0.58, 0.40), NIGHTWELL); - m.spawn("Owl", 3, m.at(0.58, 0.60), NIGHTWELL); + 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 == MOSSGRIM { - m.log("Mossgrims Kern ist zerbrochen."); + if side == global::MOSSGRIM { + m.log("Mossgrim's core has shattered."); } - if side == NIGHTWELL { - m.log("Lady Nightwells Kern ist zerbrochen."); + if side == global::NIGHTWELL { + m.log("Lady Nightwell's core has shattered."); } } ``` -## 14. Beispiel 7: Befreite Kreaturen lösen Wächter aus +## 14. Example 7: Freed creatures call the guards -Wenn eine angekettete Kreatur befreit wird, kommen Wachen. +**Planned** (`on_freed`, flags, `m.warn`, position helpers with `spawn_at`). + +When a chained creature is freed, guards come. ```rhai -const HEROES = 1; - 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("Die Ketten sind gebrochen."); + m.log("The chains are broken."); if kind == "Ogre" { - m.spawn("Knight", 2, m.cave_pos(cave_index), HEROES); - m.spawn("Knight", 2, m.fort_pos(0), HEROES); - m.warn("Die Ritter haben die Befreiung bemerkt."); + 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("Knight", 3, m.fort_pos(0), HEROES); - m.spawn("Priest", 3, m.fort_pos(0), HEROES); - m.log("Ein Lindwurm ist frei - und die Wachen kommen trotzdem."); + 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. Beispiel 8: Zufall mit sicherer Wiederholung +## 15. Example 8: Randomness with safe repetition -Ein Patrouillenweg wird zufällig gewählt, aber die Mission bleibt reproduzierbar, weil `m.rand_choice` benutzt wird. +**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 -const HEROES = 1; - fn on_start(m) { let path = m.rand_choice(["north", "south"]); m.set("patrol_path", path); - m.log("Die Patrouille wählt den Weg: " + path.to_string()); + m.log("The patrol takes the route: " + path.to_string()); } fn on_time(m) { @@ -903,22 +1017,24 @@ fn on_time(m) { let path = m.get("patrol_path"); if path == "north" { - m.spawn("Knight", 2, m.at(0.70, 0.30), HEROES); - m.spawn("Archer", 2, m.at(0.72, 0.36), HEROES); + 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("Knight", 2, m.at(0.70, 0.70), HEROES); - m.spawn("Archer", 2, m.at(0.72, 0.64), HEROES); + 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. Beispiel 9: Zeitlimitwarnungen +## 16. Example 9: Time limit warnings -Eine Mission mit Zeitlimit warnt kurz vor Ablauf. +**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) { @@ -930,62 +1046,66 @@ fn on_time(m) { if left <= 300 && !m.has_flag("warn_5min") { m.set_flag("warn_5min"); - m.warn("Noch fünf Minuten."); + m.warn("Five minutes left."); } if left <= 60 && !m.has_flag("warn_last_minute") { m.set_flag("warn_last_minute"); - m.warn("Die letzte Minute läuft."); + m.warn("The last minute is running."); } } fn on_end(m, won) { if !won { - m.log("Die Zeit ist abgelaufen."); + m.log("Time has run out."); } } ``` -## 17. Beispiel 10: Dynamische Briefing-Texte +## 17. Example 10: Dynamic briefing texts -Ein Skript kann objectives ändern, ohne die Missionsdatei neu zu starten. +`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("Erkunde den Schacht."); + m.objective("Explore the shaft."); } fn on_goal(m, goal) { if goal.name == "Shards" && !goal.done { - m.objective("Splitter gefunden, aber noch nicht gesichert."); + m.objective("Shard found, but not secured yet."); } if goal.name == "Shards" && goal.done { - m.objective("Sichere den Weg zurück zum Kern."); + m.objective("Secure the way back to the core."); } if goal.name == "Survive" && goal.done { - m.objective("Die Wellen sind geschlagen."); + m.objective("The waves are beaten."); } } ``` -### Beispiel 11: Grafisch gezeichnete Zone: Wand fällt, Gegner erscheinen +### Example 11: Drawn zone: the wall falls, enemies appear -Im Leveleditor wird ein Kreis namens `north_gate` gezeichnet: +**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": [0.70, 0.50], + "at": [67, 40], "radius": 3.0 } ] ``` -Das Missionsskript lädt die Editorzonen automatisch: +The mission script loads the editor zones automatically: ```rhai const HEROES = 1; @@ -993,7 +1113,7 @@ const PLAYER = 0; fn on_start(m) { m.load_editor_zones(); - m.objective("Such den Weg in die Festung."); + m.objective("Find the way into the fortress."); } fn on_unit_reaches(m, unit, zone) { @@ -1001,7 +1121,7 @@ fn on_unit_reaches(m, unit, zone) { return; } - if unit.side != PLAYER { + if unit.side != global::PLAYER { return; } @@ -1010,25 +1130,25 @@ fn on_unit_reaches(m, unit, zone) { } m.set_flag("north_gate_opened"); - m.log("Eine Kreatur hat das Nordtor erreicht."); - m.warn("Die Wand bricht!"); + 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, HEROES, 2); - m.spawn("Archer", 3, m.fort_pos(0), HEROES); + m.spawn_count_in_zone("north_gate", "Knight", 3, global::HEROES, 2); + m.spawn_at(global::HEROES, "Archer", m.fort_pos(0), 1, 3); } ``` -Wenn der Editor die Zonen automatisch in das Skript schreibt, kann `on_start(m)` auch einfach so aussehen: +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("Such den Weg in die Festung."); + m.objective("Find the way into the fortress."); } ``` -Der Editor-generierte Abschnitt wäre dann: +The editor-generated part would then be: ```rhai // --- editor-generated zones: normally not edited manually --- @@ -1037,9 +1157,11 @@ fn editor_zones(m) { } ``` -### Beispiel 12: Markierte Kacheln als Zone +### Example 12: Marked tiles as a zone -Im Leveleditor werden mehrere Kacheln markiert, zum Beispiel ein Tresorweg: +**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": [ @@ -1051,7 +1173,7 @@ Im Leveleditor werden mehrere Kacheln markiert, zum Beispiel ein Tresorweg: ] ``` -Das Skript reagiert, sobald eine Spielerkreatur diese markierten Kacheln betritt: +The script reacts as soon as a player creature steps on these marked tiles: ```rhai const HEROES = 1; @@ -1059,7 +1181,7 @@ const PLAYER = 0; fn on_start(m) { m.load_editor_zones(); - m.log("Krummfinger hat einen Weg markiert."); + m.log("Crookfinger has marked a path."); } fn on_unit_reaches(m, unit, zone) { @@ -1067,7 +1189,7 @@ fn on_unit_reaches(m, unit, zone) { return; } - if unit.side != PLAYER { + if unit.side != global::PLAYER { return; } @@ -1076,15 +1198,15 @@ fn on_unit_reaches(m, unit, zone) { } m.set_flag("vault_alarm"); - m.log("Auf dem markierten Weg ist jemand eingedrungen."); - m.warn("Tresoralarm!"); + 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, HEROES, 3); + m.spawn_count_in_zone("vault_path", "Knight", 2, global::HEROES, 3); } ``` -Wenn die Zone nicht im Editor gesetzt wird, kann dieselbe Zone auch im Skript definiert werden: +If the zone is not placed in the editor, the same zone can also be defined in the script: ```rhai fn on_start(m) { @@ -1097,31 +1219,32 @@ fn on_start(m) { } ``` -### Beispiel 13: Triggerlinie - Kreatur überquert eine Brücke +### Example 13: Trigger line - a creature crosses a bridge -Im Editor wird eine Linie über eine Brücke gezeichnet: +**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": [0.60, 0.50], - "to": [0.72, 0.50], + "from": [58, 40], + "to": [69, 40], "width": 1.0 } ] ``` -Das Skript lässt Hinterhalte erscheinen, wenn eine Kreatur die Brücke überquert: +The script sets off ambushes when a creature crosses the bridge: ```rhai -const HEROES = 1; const PLAYER = 0; fn on_start(m) { m.load_editor_zones(); - m.objective("Die Brücke ist nicht sicher."); + m.objective("The bridge is not safe."); } fn on_unit_crosses(m, unit, line) { @@ -1129,7 +1252,7 @@ fn on_unit_crosses(m, unit, line) { return; } - if unit.side != PLAYER { + if unit.side != global::PLAYER { return; } @@ -1138,26 +1261,26 @@ fn on_unit_crosses(m, unit, line) { } m.bump("bridge_ambushes"); - m.log("Die Brücke ist beobachtet."); - m.warn("Hinterhalt!"); + m.log("The bridge is being watched."); + m.warn("Ambush!"); - m.spawn("Knight", 2, m.at(0.72, 0.45), HEROES); - m.spawn("Archer", 2, m.at(0.74, 0.55), HEROES); + m.spawn_at("HEROES", "Knight", 69.5, 36.5, 1, 2); + m.spawn_at("HEROES", "Archer", 71.5, 44.5, 1, 2); } ``` -## 18. Missionsdefinition mit Skript und Briefing +## 18. Mission definition with script and briefing -Ein vollständiges Beispiel: +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": "Die erste Falle", - "story": "Die Wachen glauben, der alte Schacht sei sicher. Sie irren sich. Krummfinger hat eine Falle gebaut, aber sie braucht einen Köder.", + "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": [ - "Türme laufen nicht von allein weg.", - "Ritterboden lässt sich nur erobern, wenn keine Ritter daneben stehen." + "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, @@ -1167,7 +1290,7 @@ Ein vollständiges Beispiel: { "name": "north_gate", "shape": "circle", - "at": [0.70, 0.50], + "at": [67, 40], "radius": 3.0 }, { @@ -1180,55 +1303,55 @@ Ein vollständiges Beispiel: } ``` -Dazu `missions/custom01.rhai`: +With `missions/custom01.rhai`: ```rhai -const HEROES = 1; - fn on_start(m) { - m.log("Krummfinger: Wir haben Gäste."); - m.objective("Finde den Splitter, bevor sie merken, dass die Falle offen steht."); + 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("Splitter gefunden."); - m.objective("Halte drei kurze Wellen aus."); + 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("Knight", 2, m.at(0.70, 0.40), HEROES); + m.spawn_at("HEROES", "Knight", 67.5, 32.5, 1, 2); } if wave == 3 { - m.spawn("Knight", 2, m.at(0.70, 0.48), HEROES); - m.spawn("Knight", 2, m.at(0.70, 0.56), HEROES); - m.warn("Letzte Welle!"); + 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. Regeln für Multiplayer und Determinismus +## 19. Rules for multiplayer and determinism -Die Simulation des Spiels ist in Matches deterministisch. Skripte dürfen diese Deterministik nicht zerstören. +The game simulation is deterministic in matches. Scripts must not break that determinism. -Daher gilt: +Therefore: -1. Keine lokalen Zufallsentscheidungen, die nur auf einem Client passieren. -2. Nur `m.rand_int`, `m.rand_choice` oder andere klar bestimmte Zufallsfunktionen der Missions-API benutzen. -3. Keine Zeitereignisse, die von FPS, Kamera, Maus, Tastatur oder Grafikabhängigkeit leben. -4. `on_time` soll nur Missionslogik ausführen, keine grafischen Effekte direkt auf den Zustand schreiben. -5. Änderungen an Kreaturen, Wellen und Zielen nur über die freigegebene Missions-API. -6. Für Matches können Skripte später so laufen, dass alle Clients dieselbe Mission und denselben Seed haben. +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. -## 20. Empfohlene Schreibweise +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. -### Kurze Skripte bevorzugen +## 20. Recommended style -Gut: +### Prefer short scripts + +Good (**planned** API): ```rhai fn on_time(m) { @@ -1239,34 +1362,40 @@ fn on_time(m) { } ``` -Schlecht: +Bad: ```rhai fn on_time(m) { - for i in range(1000000) { - // zu langsam und in der Simulation gefährlich + for i in 0..1000000 { + // too slow and dangerous in the simulation } } ``` -### Flags nach Missionsphase benennen +The engine stops a script after 200,000 operations with an error. -Gut: +### Name flags after the mission phase + +**Planned** API. + +Good: ```rhai m.set_flag("phase2_started"); m.has_flag("phase2_started"); ``` -Weniger gut: +Less good: ```rhai m.set_flag("flag"); ``` -### Zähler für wiederholte Bedingungen +### Counters for repeated conditions -Gut: +**Planned** API. + +Good: ```rhai m.bump("vaults_opened"); @@ -1276,31 +1405,32 @@ if m.counter("vaults_opened") >= 3 { } ``` -### Klare Namen für Kreaturen +### Clear names for creatures and sides -Gut: +Good: ```rhai -m.spawn("Knight", 2, m.at(0.70, 0.50), HEROES); +m.spawn_at("HEROES", "Knight", 67.5, 40.5, 1, 2); ``` -Weniger gut: +Not possible: ```rhai -m.spawn(13, 2, m.at(0.70, 0.50), 1); +m.spawn_at(1, 13, 67.5, 40.5, 1, 2); ``` -Zahlen sind kurz, aber schlechter lesbar. +Creature kinds are only accepted as names. Sides may be numbers, but names read better. -## 21. Komplettes Muster-Skript für eine Mission +## 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 HEROES = 1; const MOSSGRIM = 2; fn start_intro(m) { - m.log("Krummfinger: Diese Höhle ist nicht leer."); - m.objective("Finde den Splitter und überlebe die Wellen."); + m.log("Crookfinger: This cave is not empty."); + m.objective("Find the shard and survive the waves."); } fn alarm(m, key, text) { @@ -1312,13 +1442,13 @@ fn alarm(m, key, text) { } fn reinforce_from_north(m) { - m.spawn("Knight", 2, m.at(0.70, 0.35), HEROES); - m.spawn("Archer", 2, m.at(0.72, 0.32), HEROES); + 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("Knight", 2, m.at(0.70, 0.65), HEROES); - m.spawn("Archer", 2, m.at(0.72, 0.68), HEROES); + 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) { @@ -1327,7 +1457,7 @@ fn on_start(m) { fn on_time(m) { if m.time >= 300 { - alarm(m, "alarm_5min", "Nach fünf Minuten hören die Wachen etwas."); + alarm(m, "alarm_5min", "After five minutes the guards hear something."); let roll = m.rand_choice(["north", "south"]); @@ -1339,118 +1469,131 @@ fn on_time(m) { } if m.time >= 900 { - alarm(m, "alarm_15min", "Die zweite Gruppe kommt."); + 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("Der Splitter gehört uns."); - m.objective("Halte durch, bis die letzten Wellen kommen."); + 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("Der Ansturm ist geschlagen."); + m.log("The assault is beaten."); } } fn on_rival_attacked(m, side) { - if side == MOSSGRIM && !m.has_flag("mossgrim_help") { + if side == global::MOSSGRIM && !m.has_flag("mossgrim_help") { m.set_flag("mossgrim_help"); - m.log("Mossgrim hört den Lärm."); + m.log("Mossgrim hears the noise."); m.spawn_wave(3); } } fn on_end(m, won) { if won { - m.log("Krummfinger: Gut gemacht. Der Kern wird vollständig."); + m.log("Crookfinger: Well done. The core becomes whole."); } else { - m.log("Krummfinger: Wir haben zu früh angefangen."); + m.log("Crookfinger: We started too early."); } } ``` -## 22. Was der Editor später können muss +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. -Der Leveleditor sollte mindestens diese Dinge bearbeiten: +## 22. What the editor must be able to do later -1. Missionsdaten: - - `key` - - `title` +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` + - `goals` (done) - `portals` - `treasures` - `waves` - `wave_limit` - `first_wave` - `time_limit` + - `next` and branches (done) -2. Kampagnenstruktur: - - Missionen hinzufügen, entfernen, umsortieren. - - Missionstitel, Story und Hinweise bearbeiten. - - Kampagnennamen vergeben. - - Missionen als JSON speichern und laden. +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. Kartenbearbeitung: - - Größe setzen. - - Gelände anpassen. - - Flüsse und Seen bearbeiten. - - Höhlen setzen. - - Manaquellen setzen. - - Tore setzen. - - Festungen setzen und bearbeiten. - - Startpunkte des Spielers setzen. - - Triggerzonen grafisch setzen: Kreis, Rechteck, Polygon, markierte Kacheln, Linie/Brücke. - - Zonen benennen, Radius oder Kachelmenge festlegen und automatisch ins Skript übernehmen. - - Zonen optional filtern: nur Spielerseite, nur Ritterseite, bestimmte Kreaturen, einmalig auslösbar. +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. Skriptbearbeitung: - - Skriptdatei auswählen oder im Editor schreiben. - - Editor-Triggerzonen automatisch im Skript erzeugen oder aktualisieren. - - Grafisch gesetzte Zonen im Editor verschieben, löschen oder umbenennen und ins Skript übernehmen. - - Syntaxprüfung, wenn möglich mit Rhai. - - Testmission direkt starten. - - Fehler im Skript anzeigen: - - unbekannte Funktion - - unbekannte Kreatur - - ungültige Koordinate - - ungültiges Ziel - - Endlosschleife +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: - - Titel - - Storytext - - Hinweise - - objectives - - optionale Sprache/Locale-Keys + - Title + - Story text + - Hints + - Objectives + - Optional language/locale keys -## 23. Fehlerbeispiele +## 23. Error examples -Diese Fehler sollte der Editor anzeigen: +The editor should show these errors. The examples use the implemented functions where possible. ```rhai fn on_start(m) { - m.spawn("UnknownCreature", 2, m.at(0.5, 0.5), 1); + m.spawn_at("HEROES", "UnknownCreature", 48.5, 40.5, 1, 2); } ``` -Fehler: unbekannte Kreatur. +Error: unknown creature. Implemented: rejected by the editor validation. ```rhai -fn on_time(m) { - m.spawn("Knight", 2, m.at(2.0, 0.5), 1); +fn on_start(m) { + m.spawn_count_in_zone("no_such_zone", "Knight", 3, "HEROES", 2); } ``` -Fehler: Koordinate außerhalb der Karte. +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) { @@ -1460,7 +1603,7 @@ fn on_start(m) { } ``` -Fehler: Endlosschleife oder zu viele Wiederholungen. +Error: endless loop or too many repetitions. Implemented: Rhai stops after 200,000 operations and the script is rejected. ```rhai fn on_start(m) { @@ -1468,39 +1611,49 @@ fn on_start(m) { } ``` -Fehler: unbekannte Missionsfunktion. - -## 24. Kurzfassung für Levelautoren - -Ein Missionsskript besteht normalerweise aus vier Teilen: +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 -const HEROES = 1; - fn on_start(m) { - m.objective("Kurzes Ziel."); -} - -fn on_time(m) { - // zeitgesteuerte Ereignisse -} - -fn on_goal(m, goal) { - // auf Missionsfortschritt reagieren -} - -fn on_unit_reaches(m, unit, zone) { - // auf grafisch gesetzte Zonen reagieren + m.spawn_at("HEROES", "Knight", 48, 40, 1, 2); } ``` -Die wichtigsten Werkzeuge sind: +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("Ziel"); -m.spawn("Knight", 2, m.at(0.70, 0.50), HEROES); +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"); @@ -1508,8 +1661,7 @@ m.bump("vaults_opened"); m.load_editor_zones(); m.add_zone("north_gate", m.at(0.70, 0.50), 3.0); -m.spawn_count_in_zone("north_gate", "Knight", 3, HEROES, 2); m.destroy_walls_in_zone("north_gate"); ``` -Damit lassen sich bereits viele Missionen bauen: Wellen, Bossphasen, Reaktionen, Tresorfälle, Rivalen, Zeitlimits, Location-Trigger und dynamische Briefings. +With these, many missions can be built: waves, boss phases, reactions, vault events, rivals, time limits, location triggers and dynamic briefings. diff --git a/docs/MULTIPLAYER.md b/docs/MULTIPLAYER.md index 6353401..da33b0a 100644 --- a/docs/MULTIPLAYER.md +++ b/docs/MULTIPLAYER.md @@ -53,19 +53,37 @@ per turn. Fog of war, particles and the camera are local and not part of the sha - Savegames, tests and the standalone file are unchanged: `cargo test --release` covers the command path (including "the same actions on both paths build the same world"). +## Server and client + +- **Relay** (`server/src/netplay.rs`, route `/api/netplay/ws`, proxied by nginx): rooms with a six-letter code, + seats, ready flags, a 100 ms turn clock, checksum forwarding, chat (`all` / `team` channel), pause when a player + is missing (`resume` by side 0), `abort`, `leave` (a started human seat becomes AI-controlled), `set_ai` for + empty seats before the start, and a `ping`/`pong` round trip. The message list is at the top of the file. +- **Client bridge** (`src/lobby.rs`, WebSocket in `www/dc_plugin.js`): `MatchClient` turns orders into JSON and + turns back into commands, tracks the lobby state (rooms, seats, AI seats), chat and the relay ping (`ping_ms`, + shown by the ping debug overlay). +- **Menu**: *Multiplayer* in the title menu with the match list, create (versus or co-op mission), join by code, + seat choice, AI seat toggles for the host, ready/start, chat; the match keeps running in the background while + menus are open. +- **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left are played + by the simplified rival AI on every client, so they stay deterministic. +- **Co-op** (`Game::coop`): the master sides are allies against the heroes; allied creatures never target each + other, allied ground does not block claims. Co-op campaign matches use `campaign::start_coop_mission`. + ## Steps | # | Step | Status | |---|---|---| | A | Simulation per master: gold, mana, research, rooms, hand, flag, traps, payday... per side; up to four masters (red, green, blue, yellow) | built | | B | All player actions as commands (`Command` + `Game::apply`), single player uses the same path; fixed time step | built | -| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat | next | -| D | Client: *Multiplayer* menu (lobby, create, join, ready, teams), WebSocket bridge in `dc_plugin.js`, chat, gold sending | planned | -| E | Alliances and coop rules, AI masters for free and abandoned slots | planned | -| F | Coop campaign: 20 missions with story (EN/DE) | planned | +| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat, ping | built | +| D | Client: *Multiplayer* menu (lobby, create, join, seats, ready), WebSocket bridge in `dc_plugin.js`, chat, ping | built | +| E | Alliances and co-op rules, AI masters for free and abandoned seats | partly built: co-op alliance and AI seats work; free teams in versus and sending gold are missing | +| F | Co-op campaign: 20 missions with story (EN/DE) | partly built: co-op matches reuse the single player missions that fit the master areas (`coop_mission_indexes`); no own co-op missions or story yet | -Still open on the way to C: heroes and alliances for the new sides (today the hero gate path always leads to -side 0's core, and in free play 2 and 3 are the rivals' sides), and what a match leaves out of savegames. +Still open: teams/alliances in versus, sending gold to allies, own co-op campaign missions with story; still to be +checked against the decisions above: a fallen heart only knocking out that player (who may keep watching), and how +savegames treat a running match. ## Masters and sides