diff --git a/docs/CAMPAIGN_EDITOR.md b/docs/CAMPAIGN_EDITOR.md new file mode 100644 index 0000000..c5c1cee --- /dev/null +++ b/docs/CAMPAIGN_EDITOR.md @@ -0,0 +1,769 @@ +# Kampagneneditor - Ziel, Dateiformat und Verzweigungen + +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. + +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. + +## 1. Ziel + +Der Kampagneneditor soll später Folgendes können: + +- 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. + +## 2. Start im Hauptmenü + +Der Editor soll im Hauptmenü erreichbar sein, zum Beispiel: + +```text +DungeonCraft + +- Kampagne +- Multiplayer +- Neues Spiel +- Spiel laden +- Einstellungen +- Konto +- Standalone herunterladen +- Bestiarium +- Kampagneneditor +- Hilfe +``` + +Geplante Menüaktion: + +```rhai +Btn::CampaignEditor +MenuAction::OpenCampaignEditor +Page::CampaignEditor +``` + +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. + +## 3. Kampagnendatei speichern und öffnen + +Kampagnendateien sollen weitergegeben werden können. Geplante Endung: + +```text +.dccamp +``` + +Eine `.dccamp`-Datei ist JSON. Sie enthält die vollständige Kampagne: + +- Kampagnenname +- Missionen +- Missionsziele +- Verzweigungen +- Kartenparameter +- Scripte +- Triggerzonen +- optionale Sprachtexte +- Version und Formatangaben + +Beispielhafter Export: + +```json +{ + "format": "dungeoncraft.campaign", + "version": 1, + "name": "Meine eigene Kampagne", + "author": "Spieler", + "missions": [ + { + "key": "my01", + "title": "Der erste Schacht", + "story": "Ein kleiner Posten, ein Splitter, zwei Wege.", + "size": [96, 80], + "seed": 101010, + "gold": 2500, + "goals": [ + "Shards" + ], + "next": "my02a", + "branches": [ + { + "id": "killall_path", + "goal": "KillAll", + "title": "Die Wachstube zerstören", + "next": "my02a" + }, + { + "id": "loot_path", + "goal": "Loot(3000)", + "title": "Die Tresore plündern", + "next": "my02b" + } + ], + "zones": [ + { + "name": "north_gate", + "shape": "circle", + "at": [0.70, 0.50], + "radius": 3.0 + } + ], + "script": { + "language": "rhai", + "source": "fn on_start(m) { m.log('Krummfinger: Es gibt zwei Wege.'); }" + } + } + ] +} +``` + +Geplante Menüfunktionen: + +```text +Kampagne speichern ... +Kampagne öffnen ... +Kampagne herunterladen +Kampagne testweise spielen +``` + +Im Browser kann das mit den vorhandenen Datei-APIs realisiert werden: + +- Speichern: Datei als `.dccamp` herunterladen. +- Öffnen: Datei mit Dateiauswahl öffnen und JSON lesen. +- Herunterladen: aktuelle Kampagne als `.dccamp` exportieren. + +## 4. Kampagnenstruktur + +Eine Kampagne ist nicht mehr nur eine lineare Liste von Missionen. Sie ist ein Missionsgraph. + +Vereinfacht: + +```text +my01 + | + |--> Ziel A erreicht: my02a + |--> Ziel B erreicht: my02b +``` + +Komplexer: + +```text +my01 + | + |--> KillAll: my02a + |--> Loot(3000): my02b + +my02a + | + |--> Shards: my03a + |--> Rivals: my03b + +my02b + | + |--> Freed(1): my03c + |--> Towers: my03b +``` + +Die Kampagne kann dadurch mehrere Abschnitte, alternative Wege und Wiedervereinigungen haben. + +## 5. Mission mit Verzweigung + +Eine Missionsdefinition muss später zwischen normalen Zielen und Verzweigungszielen unterscheiden. + +Geplante Bedeutung: + +- `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. + +Beispiel: + +```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.", + "goals": [ + "Shards" + ], + "branches": [ + { + "id": "kill_path", + "goal": "KillAll", + "title": "Die Wachstube zerstören", + "next": "my02a", + "priority": 1 + }, + { + "id": "loot_path", + "goal": "Loot(3000)", + "title": "Die Tresore plündern", + "next": "my02b", + "priority": 2 + } + ] +} +``` + +Regel: + +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. + +## 6. Mission mit alternativem Ziel a und b + +Beispiel mit zwei alternativen Hauptzielen: + +```json +{ + "key": "my03", + "title": "Der Nachbarsstreit", + "story": "Mossgrim oder der Orden - du kannst nur einen von beiden zuerst ausschalten.", + "goal_mode": "any", + "branches": [ + { + "id": "mossgrim_first", + "goal": "Rivals", + "title": "Mossgrim zuerst ausschalten", + "next": "my04a" + }, + { + "id": "order_first", + "goal": "Heart", + "title": "Das Tor des Ordens zuerst brechen", + "next": "my04b" + } + ] +} +``` + +Spielerlebnis: + +- 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. + +## 7. Missionsfortschritt bei Verzweigungen + +Der Kampagnenfortschritt muss später Verzweigungen speichern. + +Geplanter Fortschritt: + +```json +{ + "format": "dungeoncraft.progress", + "campaign": "Meine eigene Kampagne", + "done": { + "my01": true, + "my02b": false + }, + "completed_choice": { + "my01": "loot_path" + }, + "unlocked": [ + "my01", + "my02b" + ], + "carry": { + "my01": { + "creatures": [["Ogre", 3, 120]], + "gold": 500, + "rune_free": 0, + "rune_final": 0 + } + } +} +``` + +Regeln: + +- 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. + +Geplante Variante mit getrenntem Carry pro Branch: + +```json +"completed_choice": { + "my01": "loot_path" +}, +"branch_done": { + "my01.kill_path": false, + "my01.loot_path": true +}, +"branch_carry": { + "my01.loot_path": { + "gold": 900, + "creatures": [["Ogre", 3, 0]] + } +} +``` + +## 8. Editor-Ansicht für Kampagnenverzweigungen + +Im Editor soll es eine Kampagnenübersicht geben. + +Geplante Darstellung: + +```text +My01 Die two Wege + |- KillAll -> My02a Die Wachstube + | |- Shards -> My03a + | `- Rivals -> My03b + `- Loot(3000) -> My02b Das Schatzhaus + |- Freed(1) -> My03c + `- Towers -> My03b +``` + +Editor-Funktionen: + +- 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. + +## 9. Kampagnendatei mit Verzweigungen speichern + +Eine gespeicherte Kampagne muss die Verzweigungen enthalten. + +Beispielmission mit zwei Folgemissionen: + +```json +{ + "key": "my01", + "title": "Die zwei Wege", + "size": [96, 80], + "seed": 101010, + "gold": 2500, + "branches": [ + { + "id": "kill_path", + "goal": "KillAll", + "title": "Die Wacht zuerst ausschalten", + "next": "my02a", + "priority": 1 + }, + { + "id": "loot_path", + "goal": "Loot(3000)", + "title": "Die Tresore zuerst plündern", + "next": "my02b", + "priority": 2 + } + ], + "script": { + "language": "rhai", + "source": "fn on_start(m) { m.objective('Wähle einen Weg: die Wachstube oder die Tresore.'); }" + } +} +``` + +Wenn die Kampagne gespeichert wird, muss der Editor prüfen: + +- `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. + +## 10. Geplante Rust-Datenstruktur + +Für die Implementierung könnte der Editor mit datengetriebenen Missionen arbeiten. + +Geplante Rust-Strukturen: + +```rust +pub struct CampaignDoc { + pub format: String, + pub version: u32, + pub name: String, + pub author: String, + pub missions: Vec, +} + +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 goals: Vec, + pub waves: bool, + pub wave_limit: u32, + pub first_wave: f32, + pub time_limit: f32, + pub next: Option, + pub branches: Vec, + pub script: Option, + pub zones: Vec, +} + +pub struct BranchDoc { + pub id: String, + pub goal: String, + pub title: String, + pub next: String, + pub priority: Option, +} + +pub struct ScriptDoc { + pub language: String, + pub source: String, +} + +pub struct ZoneDoc { + pub name: String, + pub shape: String, + pub at: Option<(f32, f32)>, + pub radius: Option, + pub tiles: Option>, + pub from: Option<(f32, f32)>, + pub to: Option<(f32, f32)>, + pub width: Option, +} +``` + +Die `String`-Ziele müssen beim Spielen in echte `Goal`-Werte übersetzt werden. + +Beispiel: + +```text +"Shards" -> Goal::Shards +"Heart" -> Goal::Heart +"KillAll" -> Goal::KillAll +"Loot(3000)" -> Goal::Loot(3000) +"Survive(4)" -> Goal::Survive(4) +"Springs(2)" -> Goal::Springs(2) +"Freed(1)" -> Goal::Freed(1) +"Rivals" -> Goal::Rivals +"Towers" -> Goal::Towers +``` + +## 11. Verzweigungslogik im Spiel + +Die Spiellogik braucht einen Missionsabschluss mit Branch. + +Geplanter Ablauf: + +```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. +``` + +Pseudo-Logik: + +```rust +if mission_has_branches { + for branch in mission.branches { + if goal_is_done(goal) && mission.state != Victory { + mission.state = Victory; + mission.completed_choice = branch.id; + progress.set_done(mission.key, branch.id, carry_from_game()); + progress.unlock_mission(branch.next); + break; + } + } +} else if all_mission_goals_done() { + mission.state = Victory; +} +``` + +Bei zeitgleicher Erfüllung mehrerer Branches: + +- 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. + +## 12. Beispielmission im Kampagneneditor + +Beispiel für eine Mission mit grafischen Zonen, Script und Verzweigung: + +```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.", + "size": [104, 96], + "seed": 123456, + "gold": 3000, + "branches": [ + { + "id": "north_wand", + "goal": "KillAll", + "title": "Die Nordwand sprengen", + "next": "my06a", + "priority": 1 + }, + { + "id": "east_vault", + "goal": "Loot(4000)", + "title": "Den Tresor räumen", + "next": "my06b", + "priority": 2 + } + ], + "zones": [ + { + "name": "north_gate", + "shape": "circle", + "at": [0.70, 0.50], + "radius": 3.0 + }, + { + "name": "east_vault", + "shape": "tiles", + "tiles": [[74, 44], [74, 45], [75, 44], [75, 45]] + } + ], + "script": { + "language": "rhai", + "source": "fn on_start(m) { m.load_editor_zones(); m.objective('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}" + } +} +``` + +## 13. Editor-Fehlerprüfungen + +Der Editor soll Fehler anzeigen, bevor eine Kampagne gespeichert oder gespielt wird: + +- 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. + +## 14. Scripteditor mit Syntax-Highlighting + +Der Editor soll ein Textfeld für Rhai enthalten. + +Geplante Editorfeatures: + +- 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. + +Geplante Farben: + +```text +Keyword: hellgelb +Comment: grau +String: grün +Number: orange +Mission method: hellblau +Creature kind: cyan +Zone name: violett +Error: rot +``` + +Beispiel: + +```rhai +const HEROES = 1; +const PLAYER = 0; + +fn on_start(m) { + m.load_editor_zones(); + m.objective("Zwei Wege: Nordwand oder Tresor."); +} + +fn on_unit_reaches(m, unit, zone) { + if zone != "north_gate" { + return; + } + + if unit.side != PLAYER { + return; + } + + if m.has_flag("north_opened") { + return; + } + + m.set_flag("north_opened"); + m.warn("Die Nordwand fällt!"); + m.destroy_walls_in_zone("north_gate"); + m.spawn_count_in_zone("north_gate", "Knight", 3, HEROES, 2); +} +``` + +## 15. Kampagnenweitergabe + +Ein Spieler kann eine Kampagne als Datei weitergeben: + +```text +meine_kampagne.dccamp +``` + +Ein anderer Spieler öffnet: + +```text +Hauptmenü -> Kampagneneditor -> Kampagne öffnen -> meine_kampagne.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: + +```text +.dccamp - Kampagnendefinition +.dcsave - Spielstand +``` + +Ein Spielstand kann später auch eine aktive Kampagne referenzieren, aber die Kampagnendatei bleibt veränderbar und teilbar. + +## 16. Empfohlene Implementierungsreihenfolge + +Die Umsetzung sollte in Etappen erfolgen. + +### Etappe A: Datenmodell + +- `CampaignDoc` +- `MissionDoc` +- `BranchDoc` +- `ZoneDoc` +- `ScriptDoc` +- JSON-Laden und Speichern +- Tests für Beispielkampagnen + +### Etappe B: Kampagnenstart im Menü + +- Hauptmenüpunkt `Kampagneneditor`. +- Editorseite mit Kampagnenliste. +- Kampagne neu erstellen. +- Kampagne öffnen. +- Kampagne speichern. + +### Etappe C: Missionseditor + +- Missionsliste innerhalb einer Kampagne. +- Mission hinzufügen, entfernen, auswählen. +- Missionsdaten bearbeiten. +- Branches bearbeiten. +- Ziele bearbeiten. +- Kartenparameter bearbeiten. + +### Etappe D: Verzweigungen spielen + +- Mission endet bei Branch-Erfolg. +- Folgemission wird entsperrt. +- Progress speichert `completed_choice`. +- Carry übernimmt den gewählten Pfad. +- Kampagnenübersicht zeigt verzweigten Fortschritt. + +### Etappe E: Zoneneditor + +- Kreis zeichnen. +- Rechteck zeichnen. +- Polygon zeichnen. +- Tiles markieren. +- Linien setzen. +- Zonen automatisch ins Skript übernehmen. + +### Etappe F: Scripteditor + +- Texteditor mit Syntax-Highlighting. +- Rhai-Syntaxprüfung. +- Mission-API-Hilfe. +- Testmission starten. + +### Etappe G: Kampagnentests + +- Beispielkampagne bauen. +- Branch-Kampagne bauen. +- `.dccamp` exportieren. +- `.dccamp` importieren. +- Fortschritt speichern und laden. + +## 16a. Umgesetzter Stand + +- `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. + +## 17. Kurzfassung + +Ein Kampagneneditor für ganze Kampagnen braucht nicht nur Missionstexte, sondern einen Missionsgraphen. + +Kernidee: + +```text +Mission my01 + -> Ziel A erreicht: Mission my02a + -> Ziel B erreicht: Mission my02b +``` + +Die Kampagne wird als `.dccamp` gespeichert: + +```json +"branches": [ + { "goal": "KillAll", "next": "my02a" }, + { "goal": "Loot(3000)", "next": "my02b" } +] +``` + +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. diff --git a/docs/MISSION_SCRIPTING.md b/docs/MISSION_SCRIPTING.md new file mode 100644 index 0000000..92e95cc --- /dev/null +++ b/docs/MISSION_SCRIPTING.md @@ -0,0 +1,1515 @@ +# Mission-Scripting mit 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. + +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. + +## 1. Wofür Missionsscripting da ist + +Missionsscripting erlaubt Missionen, die nicht nur aus festen Daten bestehen: + +- 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. + +Wichtig: Skripte sollen Missionen verändern, aber nicht das ganze Spielprogramm ersetzen. Sie werden daher in einer abgesicherten Umgebung ausgeführt. + +## 2. Sprache: Rhai + +Die Missionsskripte werden in Rhai geschrieben. + +Beispiel: + +```rhai +const HERO = 1; +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."); +} +``` + +Rhai ist absichtlich einfach gehalten: + +- 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. + +## 3. Wo ein Missionsskript liegt + +Eine Mission besteht künftig aus Daten und optional einem Skript. + +Beispiel als Missionsdefinition: + +```json +{ + "key": "custom01", + "title": "Die erste Falle", + "story": "Die Wachen glauben, der alte Schacht sei sicher. Sie irren sich.", + "size": [96, 80], + "seed": 123456, + "gold": 2500, + "goals": ["Shards", "Survive(3)"], + "script": "missions/custom01.rhai" +} +``` + +Im Editor könnte das Skript direkt in einer Textbox oder in einer Datei `missions/custom01.rhai` bearbeitet werden. + +Der Kampagneneditor kann später diese Struktur vollständig speichern: + +```json +{ + "campaign": "Eigene Kampagne", + "missions": [ + { + "key": "custom01", + "title": "Die erste Falle", + "story": "Kurztext für das Briefing.", + "goals": ["Shards"], + "script": "missions/custom01.rhai" + }, + { + "key": "custom02", + "title": "Die zweite Welle", + "story": "Nach dem Splitter kommt der Orden zurück.", + "goals": ["Survive(4)"], + "script": "missions/custom02.rhai" + } + ] +} +``` + +## 4. Das Missionsobjekt `m` + +Alle Ereignisse bekommen ein Missionsobjekt `m` übergeben. Es stellt die Mission, den Spielstand und die erlaubten Aktionen bereit. + +### Lesbare Eigenschaften + +| Feld | Bedeutung | +|---|---| +| `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 | + +### Seiten + +| 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 | + +Empfohlen, benannte Konstanten zu benutzen: + +```rhai +const HEROES = 1; +const MOSSGRIM = 2; +const NIGHTWELL = 3; +``` + +### Kreaturen + +Kreaturennamen als Zeichenketten, passend zu den internen Namen: + +```rhai +"Worker" +"Kobold" +"Spider" +"Mage" +"Ogre" +"Knight" +"Archer" +"Priest" +"Dwarf" +"Paladin" +"Jailer" +"Priestess" +"Chainbreaker" +"Bat" +"Gargoyle" +"Owl" +"Wyrm" +"Cogling" +"Revenant" +"Giant" +"Fairy" +"BattleMage" +``` + +Beispiel: + +```rhai +m.spawn("Knight", 2, m.at(0.72, 0.48), HEROES); +``` + +### Ziele + +Die Missionsziele können als Zielobjekte behandelt werden: + +```rhai +goal.name +goal.count +goal.done +``` + +Namen: + +```rhai +Shards +Heart +KillAll +Loot +Towers +Survive +Rivals +Springs +Freed +``` + +Beispiel: + +```rhai +if goal.name == "Shards" && goal.done { + m.objective("Zweiter Abschnitt: Die Festung."); +} +``` + +## 5. Erlaubte Aktionen + +Die erlaubten Funktionen sind bewusst klein und sicher gehalten. + +### Text und UI + +```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."); +``` + +### Effekte + +```rhai +m.play("alarm"); +m.flash("danger"); +m.flash("treasure"); +``` + +Geplante Effekt-Namen: + +```rhai +alarm +danger +treasure +claim +level_up +heal +``` + +### Variablen, Flags und Zähler + +```rhai +m.set("phase", 1); +m.get("phase"); +m.has("phase"); + +m.set_flag("guards_alerted"); +m.has_flag("guards_alerted"); +m.clear_flag("guards_alerted"); + +m.bump("vaults_opened"); +m.counter("vaults_opened"); +``` + +Regel: Jede Aktion, die nur einmal passieren soll, wird mit einem Flag oder Zähler geschützt. + +### Positionen + +Koordinaten im Editor werden am besten als Bruchteile der Karte angegeben: + +```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. + +Weitere geplante Positionshilfen: + +```rhai +m.map_center() +m.fort_pos(0) +m.gate_pos() +m.core_pos(0) +m.cave_pos(0) +``` + +### Triggerzonen, markierte Kacheln und Linien + +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. + +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. + +#### Zonen im Skript definieren + +```rhai +fn on_start(m) { + m.add_zone("north_gate", m.at(0.70, 0.45), 2.0); + m.add_zone("south_gate", m.at(0.70, 0.55), 2.0); + m.add_zone("vault_approach", m.at(0.82, 0.50), 3.0); +} +``` + +Flächen aus markierten Kacheln: + +```rhai +fn on_start(m) { + m.add_tiles_zone("vault_path", [ + [34, 40], + [34, 41], + [35, 40] + ]); +} +``` + +Rechtecke und Linien: + +```rhai +fn on_start(m) { + m.add_rect_zone("guard_room", m.at(0.70, 0.40), m.at(0.80, 0.60)); + m.add_line("bridge_path", [m.at(0.60, 0.50), m.at(0.72, 0.50)], 1.0); +} +``` + +#### Zonen grafisch im Editor festlegen + +Der Level-/Kampagneneditor kann Zonen grafisch setzen: + +- 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. + +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. + +Beispielhafter Skriptaufruf: + +```rhai +fn on_start(m) { + m.load_editor_zones(); +} +``` + +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: + +```rhai +fn on_start(m) { + m.load_editor_zones(); + + m.add_zone("backup_path", m.at(0.58, 0.50), 2.0); +} +``` + +Der Editor kann den generierten Anteil des Skripts auch sichtbar machen: + +```rhai +// --- editor-generated zones: normally not edited manually --- +fn editor_zones(m) { + m.add_zone("north_gate", m.at(0.70, 0.50), 3.0); + m.add_tiles_zone("vault_path", [[34, 40], [34, 41], [35, 40]]); +} +``` + +Dann ruft das Missionsskript einfach `editor_zones(m)` auf. + +Zonen können auch direkt in der Missionsdefinition stehen: + +```json +"zones": [ + { + "name": "north_gate", + "shape": "circle", + "at": [0.70, 0.45], + "radius": 2.0 + }, + { + "name": "vault_approach", + "shape": "circle", + "at": [0.82, 0.50], + "radius": 3.0 + }, + { + "name": "vault_path", + "shape": "tiles", + "tiles": [[34, 40], [34, 41], [35, 40]] + }, + { + "name": "bridge_path", + "shape": "line", + "from": [0.60, 0.50], + "to": [0.72, 0.50], + "width": 1.0 + } +] +``` + +#### Zonenaktionen im Skript + +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: + +```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.destroy_walls_in_zone("north_gate"); +m.destroy_walls_at(m.at(0.72, 0.50), 2.0); + +m.destroy_doors_in_zone("vault_path"); +m.destroy_towers_in_zone("north_gate"); + +m.log("Die Zone ist offen."); +m.warn("Die Ritter kommen durch die Öffnung!"); +``` + +Beispiel: Eine Kreatur erreicht eine grafisch gesetzte Zone, die Wand fällt, und dahinter spawnen Gegner. + +```rhai +const HEROES = 1; +const PLAYER = 0; + +fn on_start(m) { + m.load_editor_zones(); + m.log("Eine markierte Zone ist im Editor gesetzt worden."); +} + +fn on_unit_reaches(m, unit, zone) { + if zone != "north_gate" { + return; + } + + if unit.side != PLAYER { + return; + } + + if m.has_flag("north_gate_opened") { + return; + } + + m.set_flag("north_gate_opened"); + m.log("Eine Kreatur hat das Nordtor erreicht."); + m.warn("Hinter der Wand warten Ritter!"); + + 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); +} +``` + +Diese Funktionen dürfen nicht willkürlich beliebige Tiles bearbeiten. Sie arbeiten nur über die Missions-API und sind deterministisch. + +### Kreaturen und Wellen erscheinen lassen + +Geplant: + +```rhai +m.spawn(kind, level, at, side); +``` + +Umgesetzt ist für jetzt, weil `spawn` in Rhai reserviert ist: + +```rhai +m.spawn_at(side, kind, x, y, count, level); +m.spawn_count_in_zone(zone, kind, count, side, level); +``` + +Beispiel: + +```rhai +m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2); +m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2); +``` + +```rhai +m.spawn("Knight", 2, m.at(0.72, 0.48), HEROES); +``` + +Gruppen über mehrere Aufrufe: + +```rhai +m.spawn("Knight", 2, m.at(0.72, 0.45), HEROES); +m.spawn("Archer", 2, m.at(0.74, 0.55), HEROES); +``` + +Wellen: + +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: + +```rhai +m.spawn_wave(6); +``` + +Geplant, aber noch nicht umgesetzt: + +```rhai +m.set_wave_limit(8); +m.set_first_wave(180); +``` + +Ziele und Limits ändern: + +```rhai +m.set_time_limit(1200); +m.add_time_limit(300); +m.set_wave_limit(10); +``` + +### Zufall + +Nur erlaubte Zufallsfunktionen benutzen: + +```rhai +m.rand_int(1, 10) +m.rand_choice(["north", "south"]) +``` + +Beispiel: + +```rhai +let path = m.rand_choice(["north", "south"]); +m.set("patrol_path", path); +``` + +## 6. Ereignis-Hooks + +Ein Missionsskript kann verschiedene Ereignisse definieren. Nicht jedes Ereignis muss vorkommen. + +| 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 | + +### Das `unit`-Objekt in Location-Triggern + +In `on_unit_reaches` und `on_unit_crosses` bekommt das Skript ein kleines `unit`-Objekt: + +```rhai +unit.id +unit.kind +unit.level +unit.side +unit.alive +unit.x +unit.y +``` + +Beispiel: + +```rhai +if unit.kind == "Ogre" && unit.side == PLAYER { + m.log("Ein Oger hat die Zone erreicht."); +} +``` + +Die Zone `zone` ist der Name der grafisch gesetzten oder im Skript definierten Zone: + +```rhai +if zone == "north_gate" { + // nur auf diese Zone reagieren +} +``` + +Triggerzonen sollten immer mit Flags oder Zählern abgesichert werden, damit dieselbe Aktion nicht mehrfach ausgelöst wird. + +### Missionsverzweigungen im Skript + +Wenn eine Mission Verzweigungsziele hat, kann das Skript auf eine erreichte Branch reagieren: + +```rhai +fn on_branch(m, branch) { + m.log("Zwei Wege: " + branch.id); + m.objective("Die Story führt weiter auf dem Weg " + branch.id); +} +``` + +Ein Branch-Objekt kann später diese Felder haben: + +```rhai +branch.id +branch.title +branch.goal +branch.next +``` + +Beispiel für zwei alternative Wege: + +```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."); + } + + if branch.goal == "Loot" { + m.log("Die Tresore sind geplündert."); + m.objective("Der Weg führt zu den Goldkammern."); + } +} +``` + +Weitere Informationen dazu stehen in `docs/CAMPAIGN_EDITOR.md`. + +## 7. Grundregel: Alles muss wiederholbar sein + +Ereignisse können mehrfach ausgelöst werden. Ein Skript soll deshalb idiomatisch Flags oder Zähler benutzen. + +Beispiel: Ein Alarm soll genau einmal ausgelöst werden. + +```rhai +fn on_fort_alarm(m, fort_index) { + let key = "alarm_fort_" + fort_index.to_string(); + + if !m.has_flag(key) { + m.set_flag(key); + m.log("Die Wache hat Alarm geschlagen!"); + m.play("alarm"); + } +} +``` + +Hilfsmuster für einmalige Aktionen: + +```rhai +fn once(m, key, action) { + if !m.has_flag(key) { + m.set_flag(key); + action(); + } +} +``` + +Verwendung: + +```rhai +fn on_start(m) { + once(m, "intro", || { + m.log("Krummfinger: Wir haben Gäste."); + m.objective("Erkunde den Schacht."); + }); +} +``` + +## 8. Beispiel 1: Minimales Startskript + +```rhai +const HEROES = 1; + +fn on_start(m) { + m.log("Krummfinger: Ein kleiner Posten, aber vielleicht ein Splitter."); + m.objective("Finde den Splitter."); +} + +fn on_goal(m, goal) { + if goal.name == "Shards" && goal.done { + m.log("Splitter gesichert."); + m.objective("Der Weg zur Zitadelle ist offen."); + } +} +``` + +## 9. Beispiel 2: Zeitgesteuerte Verstärkung + +Diese Mission lässt nach fünf Minuten Ritter aus dem Osten erscheinen. Nach zehn Minuten kommt eine zweite Gruppe. + +```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); + } + + 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!"); + } +} +``` + +## 10. Beispiel 3: Drei Phasen einer Mission + +Diese Mission hat drei Abschnitte. Jeder Abschnitt wird genau einmal ausgelöst. + +```rhai +const HEROES = 1; + +fn phase_name(m, phase) { + if phase == 1 { + return "Erste Phase"; + } + + if phase == 2 { + return "Zweite Phase"; + } + + return "Dritte Phase"; +} + +fn on_time(m) { + let phase = 0; + + if m.time >= 900 { + phase = 3; + } else if m.time >= 600 { + phase = 2; + } else if m.time >= 300 { + phase = 1; + } + + let key = "phase_" + phase.to_string(); + + if phase > 0 && !m.has_flag(key) { + m.set_flag(key); + m.log(phase_name(m, phase)); + + if phase == 1 { + m.spawn_wave(3); + m.objective("Halte die erste kleine Welle."); + } + + 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."); + } + + if phase == 3 { + m.spawn_wave(8); + m.warn("Der Orden kommt mit allem, was er hat."); + m.objective("Überlebe den letzten Ansturm."); + } + } +} +``` + +## 11. Beispiel 4: Tresor löst Boss aus + +Wenn der Spieler genug Tresore plündert, erscheint ein starker Gegner. + +```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.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."); + } + + if m.counter("opened_vaults") >= 4 { + m.log("Der Orden schickt alles, was er übrig hat."); + m.spawn_wave(6); + } +} +``` + +## 12. Beispiel 5: Bosskampf mit mehreren Phasen + +Ein Paladin wird nach und nach von weiteren Rittern unterstützt. + +```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."); + } + + if phase == 2 { + m.spawn("BattleMage", 3, m.at(0.82, 0.50), HEROES); + m.warn("Ein Schlachtenmagier betritt das Feld."); + } + + if phase == 3 { + m.spawn("Giant", 3, m.at(0.78, 0.50), HEROES); + m.log("Ein Riese zertrümmert die ersten Fallen."); + } +} + +fn on_tower_destroyed(m, kind) { + let key = "tower_" + kind.to_string(); + + if !m.has_flag(key) { + m.set_flag(key); + m.bump("towers_destroyed"); + } + + let destroyed = m.counter("towers_destroyed"); + + if destroyed >= 1 && !m.has_flag("boss_1") { + m.set_flag("boss_1"); + boss_phase(m, 1); + } + + if destroyed >= 3 && !m.has_flag("boss_2") { + m.set_flag("boss_2"); + boss_phase(m, 2); + } + + if destroyed >= 5 && !m.has_flag("boss_3") { + m.set_flag("boss_3"); + boss_phase(m, 3); + } +} +``` + +## 13. Beispiel 6: Rivalen reagieren lassen + +Ein Rivale greift zurück, sobald der Spieler zu aggressiv wird. + +```rhai +const MOSSGRIM = 2; +const NIGHTWELL = 3; + +fn on_rival_attacked(m, side) { + m.bump("rival_pressure"); + + if m.counter("rival_pressure") >= 3 && !m.has_flag("mossgrim_retaliation") { + m.set_flag("mossgrim_retaliation"); + m.log("Mossgrim spürt deinen Angriff."); + 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); + } +} + +fn on_rival_dead(m, side) { + if side == MOSSGRIM { + m.log("Mossgrims Kern ist zerbrochen."); + } + + if side == NIGHTWELL { + m.log("Lady Nightwells Kern ist zerbrochen."); + } +} +``` + +## 14. Beispiel 7: Befreite Kreaturen lösen Wächter aus + +Wenn eine angekettete Kreatur befreit wird, kommen Wachen. + +```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."); + + 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."); + } + + 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."); + } + } +} +``` + +## 15. Beispiel 8: Zufall mit sicherer Wiederholung + +Ein Patrouillenweg wird zufällig gewählt, aber die Mission bleibt reproduzierbar, weil `m.rand_choice` benutzt wird. + +```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()); +} + +fn on_time(m) { + if m.time < 300 || m.has_flag("patrol_arrived") { + return; + } + + let path = m.get("patrol_path"); + + if path == "north" { + m.spawn("Knight", 2, m.at(0.70, 0.30), HEROES); + m.spawn("Archer", 2, m.at(0.72, 0.36), HEROES); + } + + 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.set_flag("patrol_arrived"); +} +``` + +## 16. Beispiel 9: Zeitlimitwarnungen + +Eine Mission mit Zeitlimit warnt kurz vor Ablauf. + +```rhai +fn on_time(m) { + if m.time_limit <= 0 { + return; + } + + let left = m.time_limit - m.time; + + if left <= 300 && !m.has_flag("warn_5min") { + m.set_flag("warn_5min"); + m.warn("Noch fünf Minuten."); + } + + if left <= 60 && !m.has_flag("warn_last_minute") { + m.set_flag("warn_last_minute"); + m.warn("Die letzte Minute läuft."); + } +} + +fn on_end(m, won) { + if !won { + m.log("Die Zeit ist abgelaufen."); + } +} +``` + +## 17. Beispiel 10: Dynamische Briefing-Texte + +Ein Skript kann objectives ändern, ohne die Missionsdatei neu zu starten. + +```rhai +fn on_start(m) { + m.objective("Erkunde den Schacht."); +} + +fn on_goal(m, goal) { + if goal.name == "Shards" && !goal.done { + m.objective("Splitter gefunden, aber noch nicht gesichert."); + } + + if goal.name == "Shards" && goal.done { + m.objective("Sichere den Weg zurück zum Kern."); + } + + if goal.name == "Survive" && goal.done { + m.objective("Die Wellen sind geschlagen."); + } +} +``` + +### Beispiel 11: Grafisch gezeichnete Zone: Wand fällt, Gegner erscheinen + +Im Leveleditor wird ein Kreis namens `north_gate` gezeichnet: + +```json +"zones": [ + { + "name": "north_gate", + "shape": "circle", + "at": [0.70, 0.50], + "radius": 3.0 + } +] +``` + +Das Missionsskript lädt die Editorzonen automatisch: + +```rhai +const HEROES = 1; +const PLAYER = 0; + +fn on_start(m) { + m.load_editor_zones(); + m.objective("Such den Weg in die Festung."); +} + +fn on_unit_reaches(m, unit, zone) { + if zone != "north_gate" { + return; + } + + if unit.side != PLAYER { + return; + } + + if m.has_flag("north_gate_opened") { + return; + } + + m.set_flag("north_gate_opened"); + m.log("Eine Kreatur hat das Nordtor erreicht."); + m.warn("Die Wand bricht!"); + + 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); +} +``` + +Wenn der Editor die Zonen automatisch in das Skript schreibt, kann `on_start(m)` auch einfach so aussehen: + +```rhai +fn on_start(m) { + editor_zones(m); + m.objective("Such den Weg in die Festung."); +} +``` + +Der Editor-generierte Abschnitt wäre dann: + +```rhai +// --- editor-generated zones: normally not edited manually --- +fn editor_zones(m) { + m.add_zone("north_gate", m.at(0.70, 0.50), 3.0); +} +``` + +### Beispiel 12: Markierte Kacheln als Zone + +Im Leveleditor werden mehrere Kacheln markiert, zum Beispiel ein Tresorweg: + +```json +"zones": [ + { + "name": "vault_path", + "shape": "tiles", + "tiles": [[34, 40], [34, 41], [35, 40], [35, 41]] + } +] +``` + +Das Skript reagiert, sobald eine Spielerkreatur diese markierten Kacheln betritt: + +```rhai +const HEROES = 1; +const PLAYER = 0; + +fn on_start(m) { + m.load_editor_zones(); + m.log("Krummfinger hat einen Weg markiert."); +} + +fn on_unit_reaches(m, unit, zone) { + if zone != "vault_path" { + return; + } + + if unit.side != PLAYER { + return; + } + + if m.has_flag("vault_alarm") { + return; + } + + m.set_flag("vault_alarm"); + m.log("Auf dem markierten Weg ist jemand eingedrungen."); + m.warn("Tresoralarm!"); + + m.destroy_doors_in_zone("vault_path"); + m.spawn_count_in_zone("vault_path", "Knight", 2, HEROES, 3); +} +``` + +Wenn die Zone nicht im Editor gesetzt wird, kann dieselbe Zone auch im Skript definiert werden: + +```rhai +fn on_start(m) { + m.add_tiles_zone("vault_path", [ + [34, 40], + [34, 41], + [35, 40], + [35, 41] + ]); +} +``` + +### Beispiel 13: Triggerlinie - Kreatur überquert eine Brücke + +Im Editor wird eine Linie über eine Brücke gezeichnet: + +```json +"zones": [ + { + "name": "bridge_path", + "shape": "line", + "from": [0.60, 0.50], + "to": [0.72, 0.50], + "width": 1.0 + } +] +``` + +Das Skript lässt Hinterhalte erscheinen, wenn eine Kreatur die Brücke überquert: + +```rhai +const HEROES = 1; +const PLAYER = 0; + +fn on_start(m) { + m.load_editor_zones(); + m.objective("Die Brücke ist nicht sicher."); +} + +fn on_unit_crosses(m, unit, line) { + if line != "bridge_path" { + return; + } + + if unit.side != PLAYER { + return; + } + + if m.counter("bridge_ambushes") >= 2 { + return; + } + + m.bump("bridge_ambushes"); + m.log("Die Brücke ist beobachtet."); + m.warn("Hinterhalt!"); + + m.spawn("Knight", 2, m.at(0.72, 0.45), HEROES); + m.spawn("Archer", 2, m.at(0.74, 0.55), HEROES); +} +``` + +## 18. Missionsdefinition mit Skript und Briefing + +Ein vollständiges Beispiel: + +```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.", + "hints": [ + "Türme laufen nicht von allein weg.", + "Ritterboden lässt sich nur erobern, wenn keine Ritter daneben stehen." + ], + "size": [96, 80], + "seed": 123456, + "gold": 2500, + "goals": ["Shards", "Survive(3)"], + "zones": [ + { + "name": "north_gate", + "shape": "circle", + "at": [0.70, 0.50], + "radius": 3.0 + }, + { + "name": "vault_path", + "shape": "tiles", + "tiles": [[34, 40], [34, 41], [35, 40], [35, 41]] + } + ], + "script": "missions/custom01.rhai" +} +``` + +Dazu `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."); +} + +fn on_goal(m, goal) { + if goal.name == "Shards" && goal.done { + m.log("Splitter gefunden."); + m.objective("Halte drei kurze Wellen aus."); + m.spawn_wave(1); + } +} + +fn on_wave(m, wave) { + if wave == 2 { + m.spawn("Knight", 2, m.at(0.70, 0.40), HEROES); + } + + 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!"); + } +} +``` + +## 19. Regeln für Multiplayer und Determinismus + +Die Simulation des Spiels ist in Matches deterministisch. Skripte dürfen diese Deterministik nicht zerstören. + +Daher gilt: + +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. + +## 20. Empfohlene Schreibweise + +### Kurze Skripte bevorzugen + +Gut: + +```rhai +fn on_time(m) { + if m.time >= 300 && !m.has_flag("second_wave") { + m.set_flag("second_wave"); + m.spawn_wave(4); + } +} +``` + +Schlecht: + +```rhai +fn on_time(m) { + for i in range(1000000) { + // zu langsam und in der Simulation gefährlich + } +} +``` + +### Flags nach Missionsphase benennen + +Gut: + +```rhai +m.set_flag("phase2_started"); +m.has_flag("phase2_started"); +``` + +Weniger gut: + +```rhai +m.set_flag("flag"); +``` + +### Zähler für wiederholte Bedingungen + +Gut: + +```rhai +m.bump("vaults_opened"); + +if m.counter("vaults_opened") >= 3 { + m.spawn_wave(4); +} +``` + +### Klare Namen für Kreaturen + +Gut: + +```rhai +m.spawn("Knight", 2, m.at(0.70, 0.50), HEROES); +``` + +Weniger gut: + +```rhai +m.spawn(13, 2, m.at(0.70, 0.50), 1); +``` + +Zahlen sind kurz, aber schlechter lesbar. + +## 21. Komplettes Muster-Skript für eine Mission + +```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."); +} + +fn alarm(m, key, text) { + if !m.has_flag(key) { + m.set_flag(key); + m.log(text); + m.warn(text); + } +} + +fn reinforce_from_north(m) { + m.spawn("Knight", 2, m.at(0.70, 0.35), HEROES); + m.spawn("Archer", 2, m.at(0.72, 0.32), HEROES); +} + +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); +} + +fn on_start(m) { + start_intro(m); +} + +fn on_time(m) { + if m.time >= 300 { + alarm(m, "alarm_5min", "Nach fünf Minuten hören die Wachen etwas."); + + let roll = m.rand_choice(["north", "south"]); + + if roll == "north" { + reinforce_from_north(m); + } else { + reinforce_from_south(m); + } + } + + if m.time >= 900 { + alarm(m, "alarm_15min", "Die zweite Gruppe kommt."); + 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.spawn_wave(1); + } + + if goal.name == "Survive" && goal.done { + m.log("Der Ansturm ist geschlagen."); + } +} + +fn on_rival_attacked(m, side) { + if side == MOSSGRIM && !m.has_flag("mossgrim_help") { + m.set_flag("mossgrim_help"); + m.log("Mossgrim hört den Lärm."); + m.spawn_wave(3); + } +} + +fn on_end(m, won) { + if won { + m.log("Krummfinger: Gut gemacht. Der Kern wird vollständig."); + } else { + m.log("Krummfinger: Wir haben zu früh angefangen."); + } +} +``` + +## 22. Was der Editor später können muss + +Der Leveleditor sollte mindestens diese Dinge bearbeiten: + +1. Missionsdaten: + - `key` + - `title` + - `story` + - `hints` + - `size` + - `seed` + - `gold` + - `goals` + - `portals` + - `treasures` + - `waves` + - `wave_limit` + - `first_wave` + - `time_limit` + +2. Kampagnenstruktur: + - Missionen hinzufügen, entfernen, umsortieren. + - Missionstitel, Story und Hinweise bearbeiten. + - Kampagnennamen vergeben. + - Missionen als JSON speichern und laden. + +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. + +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 + +5. Briefing: + - Titel + - Storytext + - Hinweise + - objectives + - optionale Sprache/Locale-Keys + +## 23. Fehlerbeispiele + +Diese Fehler sollte der Editor anzeigen: + +```rhai +fn on_start(m) { + m.spawn("UnknownCreature", 2, m.at(0.5, 0.5), 1); +} +``` + +Fehler: unbekannte Kreatur. + +```rhai +fn on_time(m) { + m.spawn("Knight", 2, m.at(2.0, 0.5), 1); +} +``` + +Fehler: Koordinate außerhalb der Karte. + +```rhai +fn on_start(m) { + while true { + m.spawn_wave(1); + } +} +``` + +Fehler: Endlosschleife oder zu viele Wiederholungen. + +```rhai +fn on_start(m) { + m.unknown_function(); +} +``` + +Fehler: unbekannte Missionsfunktion. + +## 24. Kurzfassung für Levelautoren + +Ein Missionsskript besteht normalerweise aus vier Teilen: + +```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 +} +``` + +Die wichtigsten Werkzeuge sind: + +```rhai +m.log("Text"); +m.objective("Ziel"); +m.spawn("Knight", 2, m.at(0.70, 0.50), HEROES); +m.spawn_wave(3); +m.has_flag("phase1_done"); +m.set_flag("phase1_done"); +m.counter("vaults_opened"); +m.bump("vaults_opened"); + +m.load_editor_zones(); +m.add_zone("north_gate", m.at(0.70, 0.50), 3.0); +m.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.