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:
- Keine lokalen Zufallsentscheidungen, die nur auf einem Client passieren.
- Nur
m.rand_int,m.rand_choiceoder andere klar bestimmte Zufallsfunktionen der Missions-API benutzen. - Keine Zeitereignisse, die von FPS, Kamera, Maus, Tastatur oder Grafikabhängigkeit leben.
on_timesoll nur Missionslogik ausführen, keine grafischen Effekte direkt auf den Zustand schreiben.- Änderungen an Kreaturen, Wellen und Zielen nur über die freigegebene Missions-API.
- 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:
-
Missionsdaten:
keytitlestoryhintssizeseedgoldgoalsportalstreasureswaveswave_limitfirst_wavetime_limit
-
Kampagnenstruktur:
- Missionen hinzufügen, entfernen, umsortieren.
- Missionstitel, Story und Hinweise bearbeiten.
- Kampagnennamen vergeben.
- Missionen als JSON speichern und laden.
-
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.
-
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
-
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.