Files
dungeoncraft/docs/MISSION_SCRIPTING.md
T

35 KiB

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:

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:

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

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

const HEROES = 1;
const MOSSGRIM = 2;
const NIGHTWELL = 3;

Kreaturen

Kreaturennamen als Zeichenketten, passend zu den internen Namen:

"Worker"
"Kobold"
"Spider"
"Mage"
"Ogre"
"Knight"
"Archer"
"Priest"
"Dwarf"
"Paladin"
"Jailer"
"Priestess"
"Chainbreaker"
"Bat"
"Gargoyle"
"Owl"
"Wyrm"
"Cogling"
"Revenant"
"Giant"
"Fairy"
"BattleMage"

Beispiel:

m.spawn("Knight", 2, m.at(0.72, 0.48), HEROES);

Ziele

Die Missionsziele können als Zielobjekte behandelt werden:

goal.name
goal.count
goal.done

Namen:

Shards
Heart
KillAll
Loot
Towers
Survive
Rivals
Springs
Freed

Beispiel:

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

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

m.play("alarm");
m.flash("danger");
m.flash("treasure");

Geplante Effekt-Namen:

alarm
danger
treasure
claim
level_up
heal

Variablen, Flags und Zähler

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:

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:

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

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:

fn on_start(m) {
    m.add_tiles_zone("vault_path", [
        [34, 40],
        [34, 41],
        [35, 40]
    ]);
}

Rechtecke und Linien:

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:

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:

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:

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

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

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.

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:

m.spawn(kind, level, at, side);

Umgesetzt ist für jetzt, weil spawn in Rhai reserviert ist:

m.spawn_at(side, kind, x, y, count, level);
m.spawn_count_in_zone(zone, kind, count, side, level);

Beispiel:

m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2);
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
m.spawn("Knight", 2, m.at(0.72, 0.48), HEROES);

Gruppen über mehrere Aufrufe:

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:

m.spawn_wave(6);

Geplant, aber noch nicht umgesetzt:

m.set_wave_limit(8);
m.set_first_wave(180);

Ziele und Limits ändern:

m.set_time_limit(1200);
m.add_time_limit(300);
m.set_wave_limit(10);

Zufall

Nur erlaubte Zufallsfunktionen benutzen:

m.rand_int(1, 10)
m.rand_choice(["north", "south"])

Beispiel:

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:

unit.id
unit.kind
unit.level
unit.side
unit.alive
unit.x
unit.y

Beispiel:

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:

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:

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:

branch.id
branch.title
branch.goal
branch.next

Beispiel für zwei alternative Wege:

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.

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:

fn once(m, key, action) {
    if !m.has_flag(key) {
        m.set_flag(key);
        action();
    }
}

Verwendung:

fn on_start(m) {
    once(m, "intro", || {
        m.log("Krummfinger: Wir haben Gäste.");
        m.objective("Erkunde den Schacht.");
    });
}

8. Beispiel 1: Minimales Startskript

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.

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.

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.

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.

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.

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.

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.

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.

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.

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:

"zones": [
  {
    "name": "north_gate",
    "shape": "circle",
    "at": [0.70, 0.50],
    "radius": 3.0
  }
]

Das Missionsskript lädt die Editorzonen automatisch:

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:

fn on_start(m) {
    editor_zones(m);
    m.objective("Such den Weg in die Festung.");
}

Der Editor-generierte Abschnitt wäre dann:

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

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

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:

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:

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

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:

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

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:

fn on_time(m) {
    if m.time >= 300 && !m.has_flag("second_wave") {
        m.set_flag("second_wave");
        m.spawn_wave(4);
    }
}

Schlecht:

fn on_time(m) {
    for i in range(1000000) {
        // zu langsam und in der Simulation gefährlich
    }
}

Flags nach Missionsphase benennen

Gut:

m.set_flag("phase2_started");
m.has_flag("phase2_started");

Weniger gut:

m.set_flag("flag");

Zähler für wiederholte Bedingungen

Gut:

m.bump("vaults_opened");

if m.counter("vaults_opened") >= 3 {
    m.spawn_wave(4);
}

Klare Namen für Kreaturen

Gut:

m.spawn("Knight", 2, m.at(0.70, 0.50), HEROES);

Weniger gut:

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

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:

fn on_start(m) {
    m.spawn("UnknownCreature", 2, m.at(0.5, 0.5), 1);
}

Fehler: unbekannte Kreatur.

fn on_time(m) {
    m.spawn("Knight", 2, m.at(2.0, 0.5), 1);
}

Fehler: Koordinate außerhalb der Karte.

fn on_start(m) {
    while true {
        m.spawn_wave(1);
    }
}

Fehler: Endlosschleife oder zu viele Wiederholungen.

fn on_start(m) {
    m.unknown_function();
}

Fehler: unbekannte Missionsfunktion.

24. Kurzfassung für Levelautoren

Ein Missionsskript besteht normalerweise aus vier Teilen:

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:

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.