Files
dungeoncraft/docs/MISSION_SCRIPTING.md
T

1516 lines
35 KiB
Markdown

# 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.