1668 lines
44 KiB
Markdown
1668 lines
44 KiB
Markdown
# Mission scripting with Rhai
|
|
|
|
This guide describes the scripting language for missions and the level/campaign editor. The language is **Rhai**, because it runs directly in Rust, works in WASM, has a simple syntax and is easy to sandbox.
|
|
|
|
## Status
|
|
|
|
The document is a design draft plus a first implementation. Most of the API below is **planned**; sections and examples that use planned parts are marked with **Planned**. What exists today:
|
|
|
|
**Runtime** (`src/script.rs`, applied in `start_custom_script()` in `src/main.rs`)
|
|
|
|
- Only missions of a custom campaign (`.dccamp`, `MissionDoc.script`) run scripts. The script language must be `rhai` (or empty); any other value shows `msg.script_language`.
|
|
- One hook: `on_start(m)`, called once when the mission starts. A script without `fn on_start(` is allowed and does nothing.
|
|
- The script records actions while it runs; the game applies them right after the hook returns.
|
|
- The engine stops a script after 200,000 operations (endless loops become a script error).
|
|
- Rhai errors (syntax, unknown function, wrong argument types, too many operations) are shown in game as `msg.script_error`.
|
|
|
|
**Actions on `m`**
|
|
|
|
| Call | Effect |
|
|
|---|---|
|
|
| `m.log(text)` | shows `text` as a message (`msg.script`) |
|
|
| `m.objective(text)` | shows `Objective: text` as a message (`msg.script_objective`); the last call wins |
|
|
| `m.spawn_at(side, kind, x, y, count, level)` | spawns `count` creatures of `kind` at the world position `x`, `y` |
|
|
| `m.spawn_count_in_zone(zone, kind, count, side, level)` | spawns `count` creatures of `kind` spread over the named zone |
|
|
| `m.spawn_wave(count)` | turns the mission's hero waves on, starts the next wave right away and makes sure at least `count` more waves may come |
|
|
|
|
Argument details:
|
|
|
|
- `side`: an integer `0` to `4`, or a name: `"PLAYER"` (0), `"HEROES"` (1, also `"HERO"`), `"MOSSGRIM"` (2), `"NIGHTWELL"` (3), `"RIVAL3"` (4, also `"RIVAL"`). Names are case-insensitive.
|
|
- `kind`: a creature name from the list in [Creatures](#creatures), case-insensitive.
|
|
- `x`, `y`: **floating point** world coordinates in tiles (`21.5` is the centre of tile 21). Integer literals do not match: `m.spawn_at("HEROES", "Knight", 21, 21, 3, 2)` fails with "function not found"; write `21.0` or `21.5`. Positions outside the map are skipped silently.
|
|
- `count`, `level`, `side` (as number): integers. `count` and `level` are clamped to `1..10`; `spawn_wave` clamps `count` to `1..20`.
|
|
- `zone`: the `name` of a zone in the mission's `zones` list (see [Zones in the mission definition](#zones-in-the-mission-definition)).
|
|
|
|
**Validation** (`validate_script_source()` in `src/main.rs`)
|
|
|
|
When a script is entered in the campaign editor (*Edit script*, up to 600 characters), it is run once with `on_start` and rejected if:
|
|
|
|
- Rhai reports an error (syntax error, unknown function, wrong argument types, too many operations),
|
|
- a spawn uses an unknown creature name,
|
|
- `spawn_count_in_zone` names a zone that the mission does not define.
|
|
|
|
Not checked yet: unknown side names (such a spawn is dropped silently), unknown side numbers (reported only in game), coordinates outside the map (skipped silently). Scripts loaded from a `.dccamp` file are not validated; the same errors then show up in game as `msg.script_error`.
|
|
|
|
**Zones**: missions can store zones as data (`zones` in the `.dccamp` file, shapes `circle`, `rect`, `line`, `tiles`). They are used by `spawn_count_in_zone` and drawn by the *Debug trigger zones* setting. There is no graphical zone editor yet and no zone trigger hooks.
|
|
|
|
**Not implemented yet**: all other hooks, readable properties on `m`, flags/variables/counters, hints/warnings/effects, position helpers (`m.at`, ...), zone functions in the script, destruction actions, random functions, limit changes, goal and branch objects, and script files outside the campaign file.
|
|
|
|
**Rhai notes**
|
|
|
|
- `spawn` is a reserved word in Rhai. Even `m.spawn(...)` is a parse error, so the API uses `spawn_at`.
|
|
- Strings use double quotes. `'x'` is a single character in Rhai, and `'abc'` is a syntax error.
|
|
- Functions cannot see variables or constants of the main script. A top-level `const HEROES = 1;` must be written as `global::HEROES` inside a function; plain `HEROES` fails with "variable not found". The side names as strings (`"HEROES"`) avoid this.
|
|
|
|
## 1. What mission scripting is for
|
|
|
|
Mission scripting allows missions that are more than fixed data:
|
|
|
|
- Timed events: after 300 seconds reinforcements attack.
|
|
- Reactions: when the player loots a vault, a paladin appears.
|
|
- Phases: first waves, then a fortress, then a boss.
|
|
- Controlled randomness: a patrolling group takes one of two paths.
|
|
- Dynamic briefings and objectives.
|
|
- Rivals, caves, shards, mana springs and gates can react to the player's progress.
|
|
- Campaign missions can later be edited with their own story, goals and logic.
|
|
|
|
Important: scripts change missions, they do not replace the whole game. They therefore run in a sandbox.
|
|
|
|
## 2. Language: Rhai
|
|
|
|
Mission scripts are written in Rhai.
|
|
|
|
Example:
|
|
|
|
```rhai
|
|
const HERO = 1;
|
|
const MOSSGRIM = 2;
|
|
const NIGHTWELL = 3;
|
|
|
|
fn on_start(m) {
|
|
m.log("Crookfinger has a plan.");
|
|
m.objective("Find the shard before the guards raise the alarm.");
|
|
}
|
|
```
|
|
|
|
Rhai is deliberately simple:
|
|
|
|
- Variables with `let`.
|
|
- Constants with `const`.
|
|
- Functions with `fn`.
|
|
- `if`, `for`, `while`, booleans, numbers and strings (in double quotes).
|
|
- Arrays with `[...]`.
|
|
- Maps with `#{...}`.
|
|
- No files, no network access, no direct access to Rust memory.
|
|
|
|
Constants declared at the top are only visible inside functions as `global::NAME`:
|
|
|
|
```rhai
|
|
const HEROES = 1;
|
|
|
|
fn on_start(m) {
|
|
m.spawn_at(global::HEROES, "Knight", 21.5, 21.5, 2, 1); // works
|
|
// m.spawn_at(HEROES, ...) would fail: functions do not see HEROES
|
|
}
|
|
```
|
|
|
|
## 3. Where a mission script lives
|
|
|
|
A mission consists of data and optionally a script. Today the script is stored inside the mission in the campaign file (`.dccamp`) as an object with `language` and `source`:
|
|
|
|
```json
|
|
{
|
|
"key": "custom01",
|
|
"title": "The first trap",
|
|
"story": "The guards believe the old shaft is safe. They are wrong.",
|
|
"size": [96, 80],
|
|
"seed": 123456,
|
|
"gold": 2500,
|
|
"goals": ["Shards", "Survive(3)"],
|
|
"script": {
|
|
"language": "rhai",
|
|
"source": "fn on_start(m) { m.objective(\"Find the shard.\"); }"
|
|
}
|
|
}
|
|
```
|
|
|
|
In the campaign editor the script is edited with *Edit script* in a single-line text prompt. **Planned**: a larger text box and scripts in separate files such as `missions/custom01.rhai` (`"script": "missions/custom01.rhai"`).
|
|
|
|
The campaign file stores the whole structure:
|
|
|
|
```json
|
|
{
|
|
"format": "dungeoncraft.campaign",
|
|
"version": 1,
|
|
"name": "My campaign",
|
|
"author": "",
|
|
"missions": [
|
|
{
|
|
"key": "custom01",
|
|
"title": "The first trap",
|
|
"story": "Short text for the briefing.",
|
|
"goals": ["Shards"],
|
|
"script": { "language": "rhai", "source": "fn on_start(m) { m.objective(\"Find the shard.\"); }" }
|
|
},
|
|
{
|
|
"key": "custom02",
|
|
"title": "The second wave",
|
|
"story": "After the shard the Order comes back.",
|
|
"goals": ["Survive(4)"],
|
|
"script": { "language": "rhai", "source": "fn on_start(m) { m.spawn_wave(2); }" }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
See `docs/CAMPAIGN_EDITOR.md` for the full campaign format.
|
|
|
|
## 4. The mission object `m`
|
|
|
|
Every hook receives a mission object `m`. It provides the mission, the game state and the allowed actions. Today `m` only offers the actions listed in [Status](#status).
|
|
|
|
### Readable properties
|
|
|
|
**Planned** - none of these properties exist yet; reading them is a script error.
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `m.time` | elapsed mission time in seconds |
|
|
| `m.map_w` | map width in tiles |
|
|
| `m.map_h` | map height in tiles |
|
|
| `m.gold` | gold of the currently active master |
|
|
| `m.mana` | mana of the currently active master |
|
|
| `m.loot` | vault gold looted so far |
|
|
| `m.shards_taken` | soul core shards taken |
|
|
| `m.springs` | mana springs claimed |
|
|
| `m.freed` | chained creatures freed |
|
|
| `m.wave` | current or last hero wave number |
|
|
| `m.time_limit` | time limit in seconds, `0` means no limit |
|
|
| `m.wave_limit` | wave limit, `0` means endless |
|
|
| `m.gate_hp` | hit points of the Order's gate |
|
|
| `m.state` | `0` running, `1` victory, `2` defeat |
|
|
|
|
### Sides
|
|
|
|
Implemented for `spawn_at` and `spawn_count_in_zone`:
|
|
|
|
| Name | Number | Meaning |
|
|
|---|---|---|
|
|
| `"PLAYER"` | `0` | red master, normally the player |
|
|
| `"HEROES"` (or `"HERO"`) | `1` | the Order of knights |
|
|
| `"MOSSGRIM"` | `2` | green rival Mossgrim |
|
|
| `"NIGHTWELL"` | `3` | blue rival Lady Nightwell |
|
|
| `"RIVAL3"` (or `"RIVAL"`) | `4` | third rival, yellow |
|
|
|
|
The names are strings, not built-in constants. Either pass the string, or declare your own constants and use them as `global::NAME` inside functions:
|
|
|
|
```rhai
|
|
const HEROES = 1;
|
|
const MOSSGRIM = 2;
|
|
const NIGHTWELL = 3;
|
|
```
|
|
|
|
### Creatures
|
|
|
|
Creature names as strings, matching the internal names (`parse_spawn_kind()` in `src/script.rs`, case-insensitive):
|
|
|
|
```rhai
|
|
"Worker"
|
|
"Kobold"
|
|
"Spider"
|
|
"Mage"
|
|
"Ogre"
|
|
"Knight"
|
|
"Archer"
|
|
"Priest"
|
|
"Dwarf"
|
|
"Paladin"
|
|
"Jailer"
|
|
"Priestess"
|
|
"Chainbreaker"
|
|
"Bat"
|
|
"Gargoyle"
|
|
"Owl"
|
|
"Wyrm"
|
|
"Cogling"
|
|
"Revenant"
|
|
"Giant"
|
|
"Fairy"
|
|
"BattleMage"
|
|
```
|
|
|
|
Any other name is rejected by the editor validation.
|
|
|
|
Example:
|
|
|
|
```rhai
|
|
m.spawn_at("HEROES", "Knight", 69.5, 38.5, 1, 2);
|
|
```
|
|
|
|
### Goals
|
|
|
|
**Planned** - goal objects and `on_goal` do not exist yet.
|
|
|
|
Mission goals can be handled as goal objects:
|
|
|
|
```rhai
|
|
goal.name
|
|
goal.count
|
|
goal.done
|
|
```
|
|
|
|
Names (the same names as in the mission's `goals` list; `Loot`, `Survive`, `Springs` and `Freed` take a number there, e.g. `Survive(3)`):
|
|
|
|
```rhai
|
|
Shards
|
|
Heart
|
|
KillAll
|
|
Loot
|
|
Towers
|
|
Survive
|
|
Rivals
|
|
Springs
|
|
Freed
|
|
```
|
|
|
|
Example:
|
|
|
|
```rhai
|
|
if goal.name == "Shards" && goal.done {
|
|
m.objective("Second stage: the fortress.");
|
|
}
|
|
```
|
|
|
|
## 5. Allowed actions
|
|
|
|
The allowed functions are deliberately few and safe.
|
|
|
|
### Text and UI
|
|
|
|
Implemented: `m.log` and `m.objective`. **Planned**: `m.hint` and `m.warn`.
|
|
|
|
```rhai
|
|
m.log("Message in the log.");
|
|
m.hint("Hint for the player."); // planned
|
|
m.warn("Warning with alarm sound."); // planned
|
|
m.objective("Current objective in the interface.");
|
|
```
|
|
|
|
`m.objective` currently shows the text as a message (`Objective: ...`), not in a separate objective panel.
|
|
|
|
### Effects
|
|
|
|
**Planned.**
|
|
|
|
```rhai
|
|
m.play("alarm");
|
|
m.flash("danger");
|
|
m.flash("treasure");
|
|
```
|
|
|
|
Planned effect names:
|
|
|
|
```rhai
|
|
alarm
|
|
danger
|
|
treasure
|
|
claim
|
|
level_up
|
|
heal
|
|
```
|
|
|
|
### Variables, flags and counters
|
|
|
|
**Planned.**
|
|
|
|
```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");
|
|
```
|
|
|
|
Rule: every action that should happen only once is guarded by a flag or a counter.
|
|
|
|
### Positions
|
|
|
|
Implemented: `m.spawn_at` takes plain world coordinates in tiles (`x`, `y` as floats; `x + 0.5` is the centre of tile `x`). Zones use tile coordinates too.
|
|
|
|
**Planned**: position helpers. In the editor, coordinates are best given as fractions of the map:
|
|
|
|
```rhai
|
|
m.at(0.70, 0.50)
|
|
```
|
|
|
|
This means 70 percent of the map width and 50 percent of the map height. The game converts this to tile coordinates.
|
|
|
|
More planned position helpers:
|
|
|
|
```rhai
|
|
m.map_center()
|
|
m.fort_pos(0)
|
|
m.cave_pos(0)
|
|
m.gate_pos()
|
|
m.core_pos(0)
|
|
```
|
|
|
|
To use these positions for spawning, a planned `spawn_at` variant takes a position instead of `x`, `y`:
|
|
|
|
```rhai
|
|
m.spawn_at("HEROES", "Knight", m.at(0.72, 0.48), 1, 2); // planned
|
|
```
|
|
|
|
The examples below use plain tile coordinates where possible and assume a 96x80 map (`m.at(0.70, 0.50)` is about `67.5, 40.5` there).
|
|
|
|
### Trigger zones, marked tiles and lines
|
|
|
|
Location triggers are named places, areas or paths in the mission. The script can react when a creature reaches such a place or crosses a line, spawn creatures in a zone, or cause destruction in the zone.
|
|
|
|
Implemented: zones stored in the mission data (`zones` in the `.dccamp` file) and `m.spawn_count_in_zone`. Everything else in this section is **planned**: defining zones in the script, drawing them in the editor, zone triggers and destruction actions.
|
|
|
|
#### Defining zones in the script
|
|
|
|
**Planned.**
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
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);
|
|
}
|
|
```
|
|
|
|
Areas made of marked tiles:
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.add_tiles_zone("vault_path", [
|
|
[34, 40],
|
|
[34, 41],
|
|
[35, 40]
|
|
]);
|
|
}
|
|
```
|
|
|
|
Rectangles and lines:
|
|
|
|
```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);
|
|
}
|
|
```
|
|
|
|
#### Drawing zones in the editor
|
|
|
|
**Planned** - the campaign editor cannot draw zones yet; zones are written into the `.dccamp` file by hand.
|
|
|
|
The level/campaign editor can place zones graphically:
|
|
|
|
- Circle: drag centre and radius.
|
|
- Rectangle: drag two corners.
|
|
- Polygon: click points.
|
|
- Marked tiles: paint single tiles or an area.
|
|
- Line: draw a bridge, path, patrol route or border line.
|
|
- Optional filter: side, creature type, once only, player creatures only, enemy creatures only.
|
|
|
|
Zones placed graphically are stored in the mission data and made available to the script automatically. The script does not have to define them by hand, but it can always add more zones.
|
|
|
|
Example script call:
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.load_editor_zones();
|
|
}
|
|
```
|
|
|
|
After `m.load_editor_zones()` all zones drawn in the editor are available in the script. Additional triggers can be added afterwards:
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.load_editor_zones();
|
|
|
|
m.add_zone("backup_path", m.at(0.58, 0.50), 2.0);
|
|
}
|
|
```
|
|
|
|
The editor can also show the generated part of the script:
|
|
|
|
```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]]);
|
|
}
|
|
```
|
|
|
|
The mission script then simply calls `editor_zones(m)`.
|
|
|
|
#### Zones in the mission definition
|
|
|
|
Implemented. Zones can be stored directly in the mission definition. All coordinates are **absolute tile coordinates** (not map fractions):
|
|
|
|
```json
|
|
"zones": [
|
|
{
|
|
"name": "north_gate",
|
|
"shape": "circle",
|
|
"at": [67, 36],
|
|
"radius": 2.0
|
|
},
|
|
{
|
|
"name": "vault_approach",
|
|
"shape": "circle",
|
|
"at": [78, 40],
|
|
"radius": 3.0
|
|
},
|
|
{
|
|
"name": "guard_room",
|
|
"shape": "rect",
|
|
"from": [67, 32],
|
|
"to": [77, 48]
|
|
},
|
|
{
|
|
"name": "vault_path",
|
|
"shape": "tiles",
|
|
"tiles": [[34, 40], [34, 41], [35, 40]]
|
|
},
|
|
{
|
|
"name": "bridge_path",
|
|
"shape": "line",
|
|
"from": [58, 40],
|
|
"to": [69, 40],
|
|
"width": 1.0
|
|
}
|
|
]
|
|
```
|
|
|
|
| Shape | Fields | Spawn positions of `spawn_count_in_zone` |
|
|
|---|---|---|
|
|
| `circle` (default) | `at`, `radius` (default `2.0`); without `at` the zone sits on the player's core | evenly on a ring inside the radius, or the centre for one creature |
|
|
| `rect` (also `rectangle`, `square`) | `from`, `to` (corner tiles) | a grid across the rectangle |
|
|
| `line` (also `path`) | `from`, `to`, `width` (only drawn) | evenly along the line |
|
|
| `tiles` | `tiles` (list of `[x, y]`) | the first `count` tiles of the list |
|
|
|
|
Positions outside the map are dropped. If none is left, the game reports `no usable spawn position in zone`. **Planned**: a `polygon` shape.
|
|
|
|
#### Zone actions in the script
|
|
|
|
Once a zone is defined, the script can trigger actions in it. Implemented: `m.spawn_count_in_zone`. **Planned**: `m.spawn_in_zone`, the `destroy_*` functions and `m.warn`.
|
|
|
|
```rhai
|
|
m.spawn_in_zone("north_gate", "Knight", 3, "HEROES"); // planned
|
|
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
|
|
m.spawn_count_in_zone("north_gate", "Archer", 3, "HEROES", 1);
|
|
|
|
m.destroy_walls_in_zone("north_gate"); // planned
|
|
m.destroy_walls_at(m.at(0.72, 0.50), 2.0); // planned
|
|
|
|
m.destroy_doors_in_zone("vault_path"); // planned
|
|
m.destroy_towers_in_zone("north_gate"); // planned
|
|
|
|
m.log("The zone is open.");
|
|
m.warn("The knights are coming through the gap!"); // planned
|
|
```
|
|
|
|
Example (**planned**: zone triggers, flags, `m.warn`, `destroy_*`, `m.fort_pos`): a creature reaches a graphically placed zone, the wall falls, and enemies appear behind it.
|
|
|
|
```rhai
|
|
const HEROES = 1;
|
|
const PLAYER = 0;
|
|
|
|
fn on_start(m) {
|
|
m.load_editor_zones();
|
|
m.log("A marked zone has been placed in the editor.");
|
|
}
|
|
|
|
fn on_unit_reaches(m, unit, zone) {
|
|
if zone != "north_gate" {
|
|
return;
|
|
}
|
|
|
|
if unit.side != global::PLAYER {
|
|
return;
|
|
}
|
|
|
|
if m.has_flag("north_gate_opened") {
|
|
return;
|
|
}
|
|
|
|
m.set_flag("north_gate_opened");
|
|
m.log("A creature has reached the north gate.");
|
|
m.warn("Knights are waiting behind the wall!");
|
|
|
|
m.destroy_walls_in_zone("north_gate");
|
|
m.spawn_count_in_zone("north_gate", "Knight", 3, global::HEROES, 2);
|
|
m.spawn_at(global::HEROES, "Archer", m.fort_pos(0), 1, 3);
|
|
}
|
|
```
|
|
|
|
These functions must not edit arbitrary tiles. They only work through the mission API and are deterministic.
|
|
|
|
### Spawning creatures and waves
|
|
|
|
Implemented (`spawn` is reserved in Rhai, so the name is `spawn_at`):
|
|
|
|
```rhai
|
|
m.spawn_at(side, kind, x, y, count, level);
|
|
m.spawn_count_in_zone(zone, kind, count, side, level);
|
|
```
|
|
|
|
Example:
|
|
|
|
```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_at("HEROES", "Knight", 69.5, 38.5, 1, 2);
|
|
```
|
|
|
|
Groups through several calls:
|
|
|
|
```rhai
|
|
m.spawn_at("HEROES", "Knight", 69.5, 36.5, 1, 2);
|
|
m.spawn_at("HEROES", "Archer", 71.5, 44.5, 1, 2);
|
|
```
|
|
|
|
Creatures of side `1` are spawned as heroes; all other sides spawn creatures for that master.
|
|
|
|
Waves:
|
|
|
|
Implemented: `m.spawn_wave(count)`. It turns the mission's waves on, starts the next wave right away, and, if the mission has a wave limit, raises the limit so that at least `count` more waves can come (a limit of `0` stays endless):
|
|
|
|
```rhai
|
|
m.spawn_wave(6);
|
|
```
|
|
|
|
**Planned**:
|
|
|
|
```rhai
|
|
m.set_wave_limit(8);
|
|
m.set_first_wave(180);
|
|
```
|
|
|
|
Changing goals and limits (**planned**):
|
|
|
|
```rhai
|
|
m.set_time_limit(1200);
|
|
m.add_time_limit(300);
|
|
m.set_wave_limit(10);
|
|
```
|
|
|
|
### Randomness
|
|
|
|
**Planned.** Only use the allowed random functions:
|
|
|
|
```rhai
|
|
m.rand_int(1, 10)
|
|
m.rand_choice(["north", "south"])
|
|
```
|
|
|
|
Example:
|
|
|
|
```rhai
|
|
let path = m.rand_choice(["north", "south"]);
|
|
m.set("patrol_path", path);
|
|
```
|
|
|
|
## 6. Event hooks
|
|
|
|
A mission script can define several hooks. Not every hook has to be present.
|
|
|
|
Implemented: only `on_start(m)`. All other hooks are **planned**; defining them today is allowed but they are never called.
|
|
|
|
| Hook | When | Status |
|
|
|---|---|---|
|
|
| `on_start(m)` | mission start | implemented |
|
|
| `on_time(m)` | regularly during the mission, recommended: once per second | planned |
|
|
| `on_turn(m, turn)` | in matches, when a server turn arrives | planned |
|
|
| `on_wave_start(m)` | before a new wave | planned |
|
|
| `on_wave(m, wave)` | after a wave has started | planned |
|
|
| `on_vault_claimed(m, vault_index, value)` | when a knight vault has been captured | planned |
|
|
| `on_shard_taken(m, count)` | when a shard has been taken | planned |
|
|
| `on_tower_destroyed(m, kind)` | when a tower has been destroyed | planned |
|
|
| `on_fort_alarm(m, fort_index)` | when a fortress raises the alarm | planned |
|
|
| `on_rival_attacked(m, side)` | when a rival is attacked directly | planned |
|
|
| `on_rival_dead(m, side)` | when a rival's core is destroyed | planned |
|
|
| `on_cave_claimed(m, cave_index, chained)` | when a cave is claimed | planned |
|
|
| `on_freed(m, kind, cave_index)` | when a chained creature is freed | planned |
|
|
| `on_gate_damage(m, damage)` | when the Order's gate is hit | planned |
|
|
| `on_unit_reaches(m, unit, zone)` | when a creature reaches a trigger zone or a target | planned |
|
|
| `on_unit_crosses(m, unit, line)` | when a creature crosses a trigger line | planned |
|
|
| `on_branch(m, branch)` | when a branch goal has been reached and the next mission is decided | planned |
|
|
| `on_goal(m, goal)` | when a mission goal changes noticeably or is reached | planned |
|
|
| `on_end(m, won)` | mission end | planned |
|
|
|
|
### The `unit` object in location triggers
|
|
|
|
**Planned.** In `on_unit_reaches` and `on_unit_crosses` the script receives a small `unit` object:
|
|
|
|
```rhai
|
|
unit.id
|
|
unit.kind
|
|
unit.level
|
|
unit.side
|
|
unit.alive
|
|
unit.x
|
|
unit.y
|
|
```
|
|
|
|
Example:
|
|
|
|
```rhai
|
|
if unit.kind == "Ogre" && unit.side == global::PLAYER {
|
|
m.log("An ogre has reached the zone.");
|
|
}
|
|
```
|
|
|
|
`zone` is the name of the zone placed in the editor or defined in the script:
|
|
|
|
```rhai
|
|
if zone == "north_gate" {
|
|
// only react to this zone
|
|
}
|
|
```
|
|
|
|
Trigger zones should always be guarded by flags or counters so that the same action does not fire several times.
|
|
|
|
### Mission branches in the script
|
|
|
|
**Planned.** If a mission has branch goals, the script can react to a reached branch:
|
|
|
|
```rhai
|
|
fn on_branch(m, branch) {
|
|
m.log("Two paths: " + branch.id);
|
|
m.objective("The story continues on the path " + branch.id);
|
|
}
|
|
```
|
|
|
|
A branch object can later have these fields (the same as a branch in the mission data):
|
|
|
|
```rhai
|
|
branch.id
|
|
branch.title
|
|
branch.goal
|
|
branch.next
|
|
```
|
|
|
|
Example for two alternative paths:
|
|
|
|
```rhai
|
|
fn on_branch(m, branch) {
|
|
if branch.goal == "KillAll" {
|
|
m.log("The guardroom is destroyed.");
|
|
m.objective("The path leads to the patrol routes.");
|
|
}
|
|
|
|
if branch.goal == "Loot" {
|
|
m.log("The vaults are looted.");
|
|
m.objective("The path leads to the gold chambers.");
|
|
}
|
|
}
|
|
```
|
|
|
|
The branches themselves (choosing the next mission by goal) are implemented in the campaign data; see `docs/CAMPAIGN_EDITOR.md`.
|
|
|
|
## 7. Basic rule: everything must be repeatable
|
|
|
|
**Planned** (flags, `on_fort_alarm`, `m.play`). Events can fire several times. A script should therefore use flags or counters as a matter of habit.
|
|
|
|
Example: an alarm should fire exactly once.
|
|
|
|
```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("The guard has raised the alarm!");
|
|
m.play("alarm");
|
|
}
|
|
}
|
|
```
|
|
|
|
Helper pattern for one-time actions:
|
|
|
|
```rhai
|
|
fn once(m, key, action) {
|
|
if !m.has_flag(key) {
|
|
m.set_flag(key);
|
|
action.call();
|
|
}
|
|
}
|
|
```
|
|
|
|
Usage:
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
once(m, "intro", || {
|
|
m.log("Crookfinger: We have guests.");
|
|
m.objective("Explore the shaft.");
|
|
});
|
|
}
|
|
```
|
|
|
|
A Rhai closure is called with `action.call()`.
|
|
|
|
## 8. Example 1: Minimal start script
|
|
|
|
Works today up to `on_goal`, which is **planned** (it is never called yet).
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.log("Crookfinger: A small outpost, but maybe a shard.");
|
|
m.objective("Find the shard.");
|
|
}
|
|
|
|
fn on_goal(m, goal) {
|
|
if goal.name == "Shards" && goal.done {
|
|
m.log("Shard secured.");
|
|
m.objective("The way to the citadel is open.");
|
|
}
|
|
}
|
|
```
|
|
|
|
## 9. Example 2: Timed reinforcements
|
|
|
|
**Planned** (`on_time`, `m.time`, flags, `m.warn`).
|
|
|
|
This mission makes knights appear from the east after five minutes. After ten minutes a second group comes.
|
|
|
|
```rhai
|
|
fn on_time(m) {
|
|
if m.time >= 300 && !m.has_flag("wave_5min") {
|
|
m.set_flag("wave_5min");
|
|
m.log("Reinforcements from the east.");
|
|
m.spawn_at("HEROES", "Knight", 67.5, 38.5, 1, 2);
|
|
m.spawn_at("HEROES", "Archer", 67.5, 44.5, 1, 2);
|
|
}
|
|
|
|
if m.time >= 600 && !m.has_flag("wave_10min") {
|
|
m.set_flag("wave_10min");
|
|
m.log("Now they mean it.");
|
|
m.spawn_at("HEROES", "Knight", 71.5, 36.5, 1, 3);
|
|
m.spawn_at("HEROES", "Knight", 71.5, 48.5, 1, 3);
|
|
m.warn("Second wave!");
|
|
}
|
|
}
|
|
```
|
|
|
|
## 10. Example 3: Three phases of a mission
|
|
|
|
**Planned** (`on_time`, `m.time`, flags, `m.warn`).
|
|
|
|
This mission has three stages. Each stage fires exactly once.
|
|
|
|
```rhai
|
|
fn phase_name(m, phase) {
|
|
if phase == 1 {
|
|
return "First phase";
|
|
}
|
|
|
|
if phase == 2 {
|
|
return "Second phase";
|
|
}
|
|
|
|
return "Third 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("Hold off the first small wave.");
|
|
}
|
|
|
|
if phase == 2 {
|
|
m.spawn_wave(5);
|
|
m.spawn_at("HEROES", "Paladin", 77.5, 40.5, 1, 2);
|
|
m.objective("Beat back the reinforcements.");
|
|
}
|
|
|
|
if phase == 3 {
|
|
m.spawn_wave(8);
|
|
m.warn("The Order comes with everything it has.");
|
|
m.objective("Survive the last assault.");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## 11. Example 4: Vault triggers a boss
|
|
|
|
**Planned** (`on_vault_claimed`, counters, flags, `m.warn`).
|
|
|
|
When the player loots enough vaults, a strong enemy appears.
|
|
|
|
```rhai
|
|
fn on_vault_claimed(m, vault_index, value) {
|
|
m.log("Vault " + vault_index.to_string() + " is open: " + value.to_string());
|
|
m.bump("opened_vaults");
|
|
|
|
if m.counter("opened_vaults") == 2 {
|
|
m.set_flag("second_vault_alarm");
|
|
m.log("The guardian is summoned.");
|
|
m.spawn_at("HEROES", "Paladin", 79.5, 40.5, 1, 4);
|
|
m.warn("A paladin approaches.");
|
|
}
|
|
|
|
if m.counter("opened_vaults") >= 4 {
|
|
m.log("The Order sends everything it has left.");
|
|
m.spawn_wave(6);
|
|
}
|
|
}
|
|
```
|
|
|
|
## 12. Example 5: Boss fight with several phases
|
|
|
|
**Planned** (`on_tower_destroyed`, counters, flags, `m.warn`).
|
|
|
|
A paladin is supported by more and more knights.
|
|
|
|
```rhai
|
|
fn boss_phase(m, phase) {
|
|
if phase == 1 {
|
|
m.spawn_at("HEROES", "Knight", 73.5, 34.5, 1, 3);
|
|
m.spawn_at("HEROES", "Knight", 73.5, 46.5, 1, 3);
|
|
m.log("The paladin is escorted by knights.");
|
|
}
|
|
|
|
if phase == 2 {
|
|
m.spawn_at("HEROES", "BattleMage", 79.5, 40.5, 1, 3);
|
|
m.warn("A battle mage enters the field.");
|
|
}
|
|
|
|
if phase == 3 {
|
|
m.spawn_at("HEROES", "Giant", 75.5, 40.5, 1, 3);
|
|
m.log("A giant smashes the first traps.");
|
|
}
|
|
}
|
|
|
|
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. Example 6: Letting rivals react
|
|
|
|
**Planned** (`on_rival_attacked`, `on_rival_dead`, counters, flags).
|
|
|
|
A rival strikes back as soon as the player becomes too aggressive.
|
|
|
|
```rhai
|
|
const MOSSGRIM = 2;
|
|
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 feels your attack.");
|
|
m.spawn_wave(6);
|
|
}
|
|
|
|
if m.counter("rival_pressure") >= 6 && !m.has_flag("nightwell_retaliation") {
|
|
m.set_flag("nightwell_retaliation");
|
|
m.log("Lady Nightwell joins in.");
|
|
m.spawn_at(global::NIGHTWELL, "Owl", 55.5, 32.5, 1, 3);
|
|
m.spawn_at(global::NIGHTWELL, "Owl", 55.5, 48.5, 1, 3);
|
|
}
|
|
}
|
|
|
|
fn on_rival_dead(m, side) {
|
|
if side == global::MOSSGRIM {
|
|
m.log("Mossgrim's core has shattered.");
|
|
}
|
|
|
|
if side == global::NIGHTWELL {
|
|
m.log("Lady Nightwell's core has shattered.");
|
|
}
|
|
}
|
|
```
|
|
|
|
## 14. Example 7: Freed creatures call the guards
|
|
|
|
**Planned** (`on_freed`, flags, `m.warn`, position helpers with `spawn_at`).
|
|
|
|
When a chained creature is freed, guards come.
|
|
|
|
```rhai
|
|
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("The chains are broken.");
|
|
|
|
if kind == "Ogre" {
|
|
m.spawn_at("HEROES", "Knight", m.cave_pos(cave_index), 1, 2);
|
|
m.spawn_at("HEROES", "Knight", m.fort_pos(0), 1, 2);
|
|
m.warn("The knights have noticed the escape.");
|
|
}
|
|
|
|
if kind == "Wyrm" {
|
|
m.spawn_at("HEROES", "Knight", m.fort_pos(0), 1, 3);
|
|
m.spawn_at("HEROES", "Priest", m.fort_pos(0), 1, 3);
|
|
m.log("A wyrm is free - and the guards come anyway.");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## 15. Example 8: Randomness with safe repetition
|
|
|
|
**Planned** (`m.rand_choice`, variables, `on_time`, flags).
|
|
|
|
A patrol route is chosen at random, but the mission stays reproducible because `m.rand_choice` is used.
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
let path = m.rand_choice(["north", "south"]);
|
|
m.set("patrol_path", path);
|
|
m.log("The patrol takes the route: " + 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_at("HEROES", "Knight", 67.5, 24.5, 1, 2);
|
|
m.spawn_at("HEROES", "Archer", 69.5, 29.5, 1, 2);
|
|
}
|
|
|
|
if path == "south" {
|
|
m.spawn_at("HEROES", "Knight", 67.5, 56.5, 1, 2);
|
|
m.spawn_at("HEROES", "Archer", 69.5, 51.5, 1, 2);
|
|
}
|
|
|
|
m.set_flag("patrol_arrived");
|
|
}
|
|
```
|
|
|
|
## 16. Example 9: Time limit warnings
|
|
|
|
**Planned** (`on_time`, `on_end`, `m.time`, `m.time_limit`, flags, `m.warn`).
|
|
|
|
A mission with a time limit warns shortly before it runs out.
|
|
|
|
```rhai
|
|
fn on_time(m) {
|
|
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("Five minutes left.");
|
|
}
|
|
|
|
if left <= 60 && !m.has_flag("warn_last_minute") {
|
|
m.set_flag("warn_last_minute");
|
|
m.warn("The last minute is running.");
|
|
}
|
|
}
|
|
|
|
fn on_end(m, won) {
|
|
if !won {
|
|
m.log("Time has run out.");
|
|
}
|
|
}
|
|
```
|
|
|
|
## 17. Example 10: Dynamic briefing texts
|
|
|
|
`on_start` works today; `on_goal` is **planned**.
|
|
|
|
A script can change objectives without restarting the mission file.
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.objective("Explore the shaft.");
|
|
}
|
|
|
|
fn on_goal(m, goal) {
|
|
if goal.name == "Shards" && !goal.done {
|
|
m.objective("Shard found, but not secured yet.");
|
|
}
|
|
|
|
if goal.name == "Shards" && goal.done {
|
|
m.objective("Secure the way back to the core.");
|
|
}
|
|
|
|
if goal.name == "Survive" && goal.done {
|
|
m.objective("The waves are beaten.");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Example 11: Drawn zone: the wall falls, enemies appear
|
|
|
|
**Planned** (drawing in the editor, `load_editor_zones`, `on_unit_reaches`, flags, `m.warn`, `destroy_walls_in_zone`, `m.fort_pos`). The zone data itself already works.
|
|
|
|
A circle named `north_gate` is drawn in the level editor (tile coordinates):
|
|
|
|
```json
|
|
"zones": [
|
|
{
|
|
"name": "north_gate",
|
|
"shape": "circle",
|
|
"at": [67, 40],
|
|
"radius": 3.0
|
|
}
|
|
]
|
|
```
|
|
|
|
The mission script loads the editor zones automatically:
|
|
|
|
```rhai
|
|
const HEROES = 1;
|
|
const PLAYER = 0;
|
|
|
|
fn on_start(m) {
|
|
m.load_editor_zones();
|
|
m.objective("Find the way into the fortress.");
|
|
}
|
|
|
|
fn on_unit_reaches(m, unit, zone) {
|
|
if zone != "north_gate" {
|
|
return;
|
|
}
|
|
|
|
if unit.side != global::PLAYER {
|
|
return;
|
|
}
|
|
|
|
if m.has_flag("north_gate_opened") {
|
|
return;
|
|
}
|
|
|
|
m.set_flag("north_gate_opened");
|
|
m.log("A creature has reached the north gate.");
|
|
m.warn("The wall breaks!");
|
|
|
|
m.destroy_walls_in_zone("north_gate");
|
|
m.spawn_count_in_zone("north_gate", "Knight", 3, global::HEROES, 2);
|
|
m.spawn_at(global::HEROES, "Archer", m.fort_pos(0), 1, 3);
|
|
}
|
|
```
|
|
|
|
If the editor writes the zones into the script automatically, `on_start(m)` can simply look like this:
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
editor_zones(m);
|
|
m.objective("Find the way into the fortress.");
|
|
}
|
|
```
|
|
|
|
The editor-generated part would then be:
|
|
|
|
```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);
|
|
}
|
|
```
|
|
|
|
### Example 12: Marked tiles as a zone
|
|
|
|
**Planned** (`load_editor_zones`, `on_unit_reaches`, flags, `m.warn`, `destroy_doors_in_zone`, `add_tiles_zone`). The zone data and `spawn_count_in_zone` already work.
|
|
|
|
Several tiles are marked in the level editor, for example a path to a vault:
|
|
|
|
```json
|
|
"zones": [
|
|
{
|
|
"name": "vault_path",
|
|
"shape": "tiles",
|
|
"tiles": [[34, 40], [34, 41], [35, 40], [35, 41]]
|
|
}
|
|
]
|
|
```
|
|
|
|
The script reacts as soon as a player creature steps on these marked tiles:
|
|
|
|
```rhai
|
|
const HEROES = 1;
|
|
const PLAYER = 0;
|
|
|
|
fn on_start(m) {
|
|
m.load_editor_zones();
|
|
m.log("Crookfinger has marked a path.");
|
|
}
|
|
|
|
fn on_unit_reaches(m, unit, zone) {
|
|
if zone != "vault_path" {
|
|
return;
|
|
}
|
|
|
|
if unit.side != global::PLAYER {
|
|
return;
|
|
}
|
|
|
|
if m.has_flag("vault_alarm") {
|
|
return;
|
|
}
|
|
|
|
m.set_flag("vault_alarm");
|
|
m.log("Someone has entered the marked path.");
|
|
m.warn("Vault alarm!");
|
|
|
|
m.destroy_doors_in_zone("vault_path");
|
|
m.spawn_count_in_zone("vault_path", "Knight", 2, global::HEROES, 3);
|
|
}
|
|
```
|
|
|
|
If the zone is not placed in the editor, the same zone can also be defined in the script:
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.add_tiles_zone("vault_path", [
|
|
[34, 40],
|
|
[34, 41],
|
|
[35, 40],
|
|
[35, 41]
|
|
]);
|
|
}
|
|
```
|
|
|
|
### Example 13: Trigger line - a creature crosses a bridge
|
|
|
|
**Planned** (`load_editor_zones`, `on_unit_crosses`, counters, `m.warn`). The line zone data already works.
|
|
|
|
A line across a bridge is drawn in the editor (tile coordinates):
|
|
|
|
```json
|
|
"zones": [
|
|
{
|
|
"name": "bridge_path",
|
|
"shape": "line",
|
|
"from": [58, 40],
|
|
"to": [69, 40],
|
|
"width": 1.0
|
|
}
|
|
]
|
|
```
|
|
|
|
The script sets off ambushes when a creature crosses the bridge:
|
|
|
|
```rhai
|
|
const PLAYER = 0;
|
|
|
|
fn on_start(m) {
|
|
m.load_editor_zones();
|
|
m.objective("The bridge is not safe.");
|
|
}
|
|
|
|
fn on_unit_crosses(m, unit, line) {
|
|
if line != "bridge_path" {
|
|
return;
|
|
}
|
|
|
|
if unit.side != global::PLAYER {
|
|
return;
|
|
}
|
|
|
|
if m.counter("bridge_ambushes") >= 2 {
|
|
return;
|
|
}
|
|
|
|
m.bump("bridge_ambushes");
|
|
m.log("The bridge is being watched.");
|
|
m.warn("Ambush!");
|
|
|
|
m.spawn_at("HEROES", "Knight", 69.5, 36.5, 1, 2);
|
|
m.spawn_at("HEROES", "Archer", 71.5, 44.5, 1, 2);
|
|
}
|
|
```
|
|
|
|
## 18. Mission definition with script and briefing
|
|
|
|
A complete example. **Planned** parts: the `hints` field and scripts in separate files (today the script goes into `"script": { "language": "rhai", "source": "..." }`), and every hook except `on_start`.
|
|
|
|
```json
|
|
{
|
|
"key": "custom01",
|
|
"title": "The first trap",
|
|
"story": "The guards believe the old shaft is safe. They are wrong. Crookfinger has built a trap, but it needs bait.",
|
|
"hints": [
|
|
"Towers do not walk away on their own.",
|
|
"Knight ground can only be claimed when no knights stand next to it."
|
|
],
|
|
"size": [96, 80],
|
|
"seed": 123456,
|
|
"gold": 2500,
|
|
"goals": ["Shards", "Survive(3)"],
|
|
"zones": [
|
|
{
|
|
"name": "north_gate",
|
|
"shape": "circle",
|
|
"at": [67, 40],
|
|
"radius": 3.0
|
|
},
|
|
{
|
|
"name": "vault_path",
|
|
"shape": "tiles",
|
|
"tiles": [[34, 40], [34, 41], [35, 40], [35, 41]]
|
|
}
|
|
],
|
|
"script": "missions/custom01.rhai"
|
|
}
|
|
```
|
|
|
|
With `missions/custom01.rhai`:
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.log("Crookfinger: We have guests.");
|
|
m.objective("Find the shard before they notice that the trap is open.");
|
|
}
|
|
|
|
fn on_goal(m, goal) {
|
|
if goal.name == "Shards" && goal.done {
|
|
m.log("Shard found.");
|
|
m.objective("Hold out against three short waves.");
|
|
m.spawn_wave(1);
|
|
}
|
|
}
|
|
|
|
fn on_wave(m, wave) {
|
|
if wave == 2 {
|
|
m.spawn_at("HEROES", "Knight", 67.5, 32.5, 1, 2);
|
|
}
|
|
|
|
if wave == 3 {
|
|
m.spawn_at("HEROES", "Knight", 67.5, 38.5, 1, 2);
|
|
m.spawn_at("HEROES", "Knight", 67.5, 45.5, 1, 2);
|
|
m.warn("Last wave!");
|
|
}
|
|
}
|
|
```
|
|
|
|
## 19. Rules for multiplayer and determinism
|
|
|
|
The game simulation is deterministic in matches. Scripts must not break that determinism.
|
|
|
|
Therefore:
|
|
|
|
1. No local random decisions that happen on only one client.
|
|
2. Only use `m.rand_int`, `m.rand_choice` or other well-defined random functions of the mission API.
|
|
3. No timed events that depend on FPS, camera, mouse, keyboard or graphics.
|
|
4. `on_time` should only run mission logic and not write graphical effects directly into the state.
|
|
5. Change creatures, waves and goals only through the released mission API.
|
|
6. For matches, scripts can later run so that all clients have the same mission and the same seed.
|
|
|
|
Today `on_start` runs once when a custom campaign mission starts, and Rhai itself has no access to time or randomness here (`no_time` is enabled), so the current runtime is deterministic.
|
|
|
|
## 20. Recommended style
|
|
|
|
### Prefer short scripts
|
|
|
|
Good (**planned** API):
|
|
|
|
```rhai
|
|
fn on_time(m) {
|
|
if m.time >= 300 && !m.has_flag("second_wave") {
|
|
m.set_flag("second_wave");
|
|
m.spawn_wave(4);
|
|
}
|
|
}
|
|
```
|
|
|
|
Bad:
|
|
|
|
```rhai
|
|
fn on_time(m) {
|
|
for i in 0..1000000 {
|
|
// too slow and dangerous in the simulation
|
|
}
|
|
}
|
|
```
|
|
|
|
The engine stops a script after 200,000 operations with an error.
|
|
|
|
### Name flags after the mission phase
|
|
|
|
**Planned** API.
|
|
|
|
Good:
|
|
|
|
```rhai
|
|
m.set_flag("phase2_started");
|
|
m.has_flag("phase2_started");
|
|
```
|
|
|
|
Less good:
|
|
|
|
```rhai
|
|
m.set_flag("flag");
|
|
```
|
|
|
|
### Counters for repeated conditions
|
|
|
|
**Planned** API.
|
|
|
|
Good:
|
|
|
|
```rhai
|
|
m.bump("vaults_opened");
|
|
|
|
if m.counter("vaults_opened") >= 3 {
|
|
m.spawn_wave(4);
|
|
}
|
|
```
|
|
|
|
### Clear names for creatures and sides
|
|
|
|
Good:
|
|
|
|
```rhai
|
|
m.spawn_at("HEROES", "Knight", 67.5, 40.5, 1, 2);
|
|
```
|
|
|
|
Not possible:
|
|
|
|
```rhai
|
|
m.spawn_at(1, 13, 67.5, 40.5, 1, 2);
|
|
```
|
|
|
|
Creature kinds are only accepted as names. Sides may be numbers, but names read better.
|
|
|
|
## 21. Complete sample script for a mission
|
|
|
|
**Planned** - uses many planned hooks and functions (`on_time`, `on_goal`, `on_rival_attacked`, `on_end`, `m.time`, flags, `m.warn`, `m.rand_choice`).
|
|
|
|
```rhai
|
|
const MOSSGRIM = 2;
|
|
|
|
fn start_intro(m) {
|
|
m.log("Crookfinger: This cave is not empty.");
|
|
m.objective("Find the shard and survive the waves.");
|
|
}
|
|
|
|
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_at("HEROES", "Knight", 67.5, 28.5, 1, 2);
|
|
m.spawn_at("HEROES", "Archer", 69.5, 25.5, 1, 2);
|
|
}
|
|
|
|
fn reinforce_from_south(m) {
|
|
m.spawn_at("HEROES", "Knight", 67.5, 52.5, 1, 2);
|
|
m.spawn_at("HEROES", "Archer", 69.5, 54.5, 1, 2);
|
|
}
|
|
|
|
fn on_start(m) {
|
|
start_intro(m);
|
|
}
|
|
|
|
fn on_time(m) {
|
|
if m.time >= 300 {
|
|
alarm(m, "alarm_5min", "After five minutes the guards hear something.");
|
|
|
|
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", "The second group is coming.");
|
|
m.spawn_wave(2);
|
|
}
|
|
}
|
|
|
|
fn on_goal(m, goal) {
|
|
if goal.name == "Shards" && goal.done {
|
|
m.log("The shard is ours.");
|
|
m.objective("Hold out until the last waves come.");
|
|
m.spawn_wave(1);
|
|
}
|
|
|
|
if goal.name == "Survive" && goal.done {
|
|
m.log("The assault is beaten.");
|
|
}
|
|
}
|
|
|
|
fn on_rival_attacked(m, side) {
|
|
if side == global::MOSSGRIM && !m.has_flag("mossgrim_help") {
|
|
m.set_flag("mossgrim_help");
|
|
m.log("Mossgrim hears the noise.");
|
|
m.spawn_wave(3);
|
|
}
|
|
}
|
|
|
|
fn on_end(m, won) {
|
|
if won {
|
|
m.log("Crookfinger: Well done. The core becomes whole.");
|
|
} else {
|
|
m.log("Crookfinger: We started too early.");
|
|
}
|
|
}
|
|
```
|
|
|
|
Note: as in the original design, the `m.time >= 300` and `m.time >= 900` branches would run every second once reached; the reinforcements are not guarded by a flag, and `m.spawn_wave(2)` repeats. A real script should guard them like `alarm()` does.
|
|
|
|
## 22. What the editor must be able to do later
|
|
|
|
The level editor should be able to edit at least the following. Items marked "(done)" exist in the campaign editor today; everything else is **planned**.
|
|
|
|
1. Mission data:
|
|
- `key` (generated when a mission is added)
|
|
- `title` (done)
|
|
- `story`
|
|
- `hints`
|
|
- `size`
|
|
- `seed`
|
|
- `gold`
|
|
- `goals` (done)
|
|
- `portals`
|
|
- `treasures`
|
|
- `waves`
|
|
- `wave_limit`
|
|
- `first_wave`
|
|
- `time_limit`
|
|
- `next` and branches (done)
|
|
|
|
2. Campaign structure:
|
|
- Add and remove missions (done), reorder them.
|
|
- Edit mission title (done), story and hints.
|
|
- Set the campaign name (done).
|
|
- Save and load missions as JSON (done, `.dccamp`).
|
|
|
|
3. Map editing:
|
|
- Set the size.
|
|
- Adjust the terrain.
|
|
- Edit rivers and lakes.
|
|
- Place caves.
|
|
- Place mana springs.
|
|
- Place gates.
|
|
- Place and edit fortresses.
|
|
- Set the player's starting points.
|
|
- Place trigger zones graphically: circle, rectangle, polygon, marked tiles, line/bridge.
|
|
- Name zones, set radius or tile set and add them to the script automatically.
|
|
- Optionally filter zones: player side only, knight side only, certain creatures, fire once.
|
|
|
|
4. Script editing:
|
|
- Choose a script file or write the script in the editor (done: single-line prompt, up to 600 characters).
|
|
- Generate or update editor trigger zones in the script automatically.
|
|
- Move, delete or rename graphically placed zones in the editor and update the script.
|
|
- Syntax check with Rhai (done).
|
|
- Start a test mission directly (done: start a mission from the editor).
|
|
- Show script errors:
|
|
- unknown function (done, through Rhai when `on_start` runs)
|
|
- unknown creature (done)
|
|
- unknown zone (done)
|
|
- invalid coordinate
|
|
- invalid goal (done for the mission's `goals`, not inside scripts)
|
|
- endless loop (done, operation limit)
|
|
- unknown side name
|
|
|
|
5. Briefing:
|
|
- Title
|
|
- Story text
|
|
- Hints
|
|
- Objectives
|
|
- Optional language/locale keys
|
|
|
|
## 23. Error examples
|
|
|
|
The editor should show these errors. The examples use the implemented functions where possible.
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.spawn_at("HEROES", "UnknownCreature", 48.5, 40.5, 1, 2);
|
|
}
|
|
```
|
|
|
|
Error: unknown creature. Implemented: rejected by the editor validation.
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.spawn_count_in_zone("no_such_zone", "Knight", 3, "HEROES", 2);
|
|
}
|
|
```
|
|
|
|
Error: unknown zone. Implemented: rejected by the editor validation.
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.spawn_at("HEROES", "Knight", 192.5, 40.5, 1, 2);
|
|
}
|
|
```
|
|
|
|
Error: coordinate outside the map. **Not reported yet**: the spawn is skipped silently.
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
while true {
|
|
m.spawn_wave(1);
|
|
}
|
|
}
|
|
```
|
|
|
|
Error: endless loop or too many repetitions. Implemented: Rhai stops after 200,000 operations and the script is rejected.
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.unknown_function();
|
|
}
|
|
```
|
|
|
|
Error: unknown mission function. Implemented: Rhai reports "function not found" when `on_start` runs, so the editor rejects it. Inside hooks that are not called during validation, it would only show up in game.
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.spawn_at("HEROES", "Knight", 48, 40, 1, 2);
|
|
}
|
|
```
|
|
|
|
Error: wrong argument types. Implemented: Rhai reports "function not found" because `x` and `y` must be floats (`48.0`, `40.0`).
|
|
|
|
## 24. Summary for level authors
|
|
|
|
A mission script usually has four parts (only `on_start` is called today):
|
|
|
|
```rhai
|
|
fn on_start(m) {
|
|
m.objective("Short objective.");
|
|
}
|
|
|
|
fn on_time(m) {
|
|
// timed events (planned)
|
|
}
|
|
|
|
fn on_goal(m, goal) {
|
|
// react to mission progress (planned)
|
|
}
|
|
|
|
fn on_unit_reaches(m, unit, zone) {
|
|
// react to graphically placed zones (planned)
|
|
}
|
|
```
|
|
|
|
The most important tools:
|
|
|
|
```rhai
|
|
// implemented
|
|
m.log("Text");
|
|
m.objective("Objective");
|
|
m.spawn_at("HEROES", "Knight", 67.5, 40.5, 1, 2);
|
|
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
|
|
m.spawn_wave(3);
|
|
|
|
// planned
|
|
m.has_flag("phase1_done");
|
|
m.set_flag("phase1_done");
|
|
m.counter("vaults_opened");
|
|
m.bump("vaults_opened");
|
|
|
|
m.load_editor_zones();
|
|
m.add_zone("north_gate", m.at(0.70, 0.50), 3.0);
|
|
m.destroy_walls_in_zone("north_gate");
|
|
```
|
|
|
|
With these, many missions can be built: waves, boss phases, reactions, vault events, rivals, time limits, location triggers and dynamic briefings.
|