Wait and both kinds of until loops; blocks run directly

Script runs are resumable threads saved with the game; Rhai is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 18:57:36 +01:00
1 parent 95cf3ce81c
commit d17668f3b2
13 files changed
+466 -686

No files matched your search

+3 -4
View File
@@ -69,11 +69,10 @@ But before committing a completed change, the full local checks should pass unle
## Campaign scripting conventions
- Mission scripts are **blocks** (`src/blocks.rs`), edited in the in-game block editor (`src/editor/blocks_ui.rs`); players never see code.
- Blocks are translated into Rhai internally and run by `src/mscript.rs` inside the fixed simulation step (deterministic, co-op safe).
- The Rust Rhai dependency must keep `no_time` enabled in `Cargo.toml` so the WASM output stays loadable by the macroquad JS bundle.
- Rhai reserves `spawn`; the internal API uses `spawn_unit`.
- The blocks are run directly by the interpreter in `src/mscript.rs` inside the fixed simulation step (deterministic, co-op safe); there is no scripting language underneath (Rhai was removed when "wait" came).
- Script runs are threads (resumable after "wait", saved with the game); loops have a step budget per check so nothing can hang.
- Per mission: special scripts (start, victory check, defeat check, clock) and triggers (`TriggerKind` in `src/campaign_doc.rs`).
- New block kinds need: the `Block`/`Val` variant, `template`/`kind_name`, palette entry, checking, Rhai translation, the API function in `mscript.rs`, the editor fields in `blocks_ui.rs`, `blk.*` texts in every language file, and a test.
- New block kinds need: the `Block`/`Val` variant, `template`/`kind_name`, palette entry, checking (`blocks::check`, `vars_set`), execution in `mscript.rs` (`exec`/`run_thread`/`eval`), nesting in `list_at` (mscript) and `list_mut` (blocks_ui) if it has a body, the editor fields in `blocks_ui.rs`, `blk.*` texts in every language file, and a test.
- Side numbers: -1 any, 0 player, 1 heroes, 2 Mossgrim, 3 Lady Nightwell, 4 rival 3.
- Unknown creature names, zones, triggers and unset variables are reported by `blocks::check`.
- Reference: `docs/CAMPAIGN_EDITOR.md` and `docs/MISSION_SCRIPTING.md`.
Generated
+2 -319
View File
@@ -8,19 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -39,18 +26,6 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -63,15 +38,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -84,39 +50,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "compact_str"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"static_assertions",
"zmij",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -135,18 +68,11 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "dungeoncraft"
version = "0.1.0"
dependencies = [
"macroquad",
"rhai",
"serde",
"serde_json",
]
@@ -193,41 +119,6 @@ dependencies = [
"ttf-parser",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "glam"
version = "0.27.0"
@@ -264,17 +155,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.191"
@@ -378,40 +258,19 @@ dependencies = [
"malloc_buf",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"portable-atomic",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -436,41 +295,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rhai"
version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3925b784c0bee6542637e425ed7511a63257bf11b7ecf51ffd845e2d24d9163"
dependencies = [
"ahash",
"bitflags 2.13.2",
"compact_str",
"num-traits",
"once_cell",
"rhai_codegen",
"smallvec",
"smartstring",
"thin-vec",
"web-time",
]
[[package]]
name = "rhai_codegen"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d9661edb4a4b8145b017582d634c5eb88ff1a5284a5aa701f83a28f0657a826"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
@@ -498,7 +322,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
"syn",
]
[[package]]
@@ -520,46 +344,6 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
@@ -571,21 +355,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "thin-vec"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e"
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -601,72 +370,6 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
dependencies = [
"cfg-if",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -689,26 +392,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
-1
View File
@@ -6,7 +6,6 @@ license = "MIT"
[dependencies]
macroquad = { version = "0.4" }
rhai = { version = "1.27.0", default-features = false, features = ["std", "no_time"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+18 -22
View File
@@ -1,12 +1,12 @@
# Mission scripting with blocks
Missions of editor campaigns are scripted with **blocks** (like Blockly): coloured puzzle pieces
put together in the block editor (see [CAMPAIGN_EDITOR.md](CAMPAIGN_EDITOR.md)). Players never see
code – internally the blocks are translated into Rhai, compiled once per mission and run inside the
game's fixed simulation step, so a co-op match runs them identically on every computer.
put together in the block editor (see [CAMPAIGN_EDITOR.md](CAMPAIGN_EDITOR.md)). There is no code:
the game runs the blocks directly, inside its fixed simulation step, so a co-op match runs them
identically on every computer.
Code: `src/blocks.rs` (blocks, values, checking, translation), `src/mscript.rs` (run time, triggers,
zones), `src/campaign_doc.rs` (`MissionScripts`, `TriggerDoc`, `TriggerKind`).
Code: `src/blocks.rs` (blocks, values, checking), `src/mscript.rs` (run time, triggers, zones),
`src/campaign_doc.rs` (`MissionScripts`, `TriggerDoc`, `TriggerKind`).
## Scripts of a mission
@@ -64,6 +64,9 @@ creatures and heroes.
| | Gold + / Mana + | amount (negative takes) |
| Logic | If / else | condition; blocks inside |
| | Repeat | times (0–100); blocks inside |
| | Repeat until | condition, checked **before** each run (already true: the inside never runs); blocks inside |
| | Repeat, then check: until | condition, checked **after** each run (the inside runs at least once); blocks inside |
| | Wait | seconds – the script pauses here and goes on with the next block later (also inside loops and "if") |
| | Switch trigger | trigger, on/off |
| Variables | Set | variable, value |
| | Change | variable, value to add |
@@ -91,28 +94,21 @@ campaign file check (`CampaignDoc::validate`).
## At run time (`src/mscript.rs`)
- The mission's program is generated by `mscript::program` – one Rhai function per script and
trigger (`s_start`, `s_victory`, `s_defeat`, `s_tick`, `t_0`, `t_1`, ...) – and compiled once.
- Scripts read the game (`m.gold()`, `m.count(side, kind)`, `m.in_zone(zone, side, kind)`, ...)
and collect actions (`m.message`, `m.spawn_unit`, `m.terrain`, `m.win`, ...), which are applied
after the call. Variables, the random state, fired triggers and zone contents are part of
`ScriptState` and saved with the game.
- Every run of a script (the start, each second's checks, each fired trigger) is a **thread**: the
lists of blocks it is in and where. *Wait* parks a thread until its time; threads are saved with
the game, so a waiting script goes on after loading.
- The victory check, defeat check and clock start every second, but not while their previous run is
still waiting. A trigger that fires again while its last run waits starts a second run.
- Loops without a *Wait* run up to 2000 blocks per check (ten checks a second) and then go on at the
next check – a script can never hang the game.
- Blocks act at once, in order: a *Count* after a *Spawn* already sees the new units. Variables, the
random state, fired triggers, zone contents and waiting threads are part of `ScriptState`.
- Scripts always run from the first player's side; messages reach every player of a match.
- A script error stops nothing else: it is shown once as a message and written to the test log.
- Test play shows the variables and the trigger log at the top right, and the zones on the map.
### Rhai API (internal)
`gold() mana() time() get(name) set(name, v) count(side, kind) in_zone(zone, side, kind)
random(a, b) div(a, b) times(n) event_is(kind) message(t) objective(t) hint(t)
spawn_unit(side, kind, count, level, place, zone, name) wave(n) add_gold(n) add_mana(n) win() lose()
reveal(zone) camera(zone) terrain(zone, kind) kill(zone, side) trigger(name, on)` – numbers are
floats; `spawn` is a reserved word in Rhai, hence `spawn_unit`. The Rhai dependency keeps the
`no_time` feature (WASM).
## The demo
*Open the demo* in the campaign editor: mission 1 ("The Trial of Triggers") uses all 14 trigger
kinds, all special scripts and nearly every block on a painted map with five zones (rectangle,
kinds, all special scripts and every block (including both kinds of *until* loops and *Wait*) on a painted map with five zones (rectangle,
circle, line, tiles); mission 2 is a random map behind a branch. Play it with *Test play* and watch
the debug view.
+7
View File
@@ -1331,3 +1331,10 @@ demo.m2.title = Neu gewürfelt
demo.m2.story = Diese Karte wird für jeden Durchgang der Kampagne neu gewürfelt. Besiege alle Helden.
demo.m2.start = Eine Zufallskarte. Selbst ich habe sie noch nie gesehen.
demo.m2.wave = Zwei Minuten sind um: Die Helden kommen.
blk.until = Wiederhole bis
blk.dountil = Wiederhole, prüfe danach: bis
blk.wait = Warte
blk.w_seconds = Sekunden
blk.help.until = Führt die Blöcke darin immer wieder aus, bis die Bedingung gilt - geprüft vor jedem Durchlauf; gilt sie schon, läuft der Inhalt gar nicht.
blk.help.dountil = Führt die Blöcke darin aus, prüft dann die Bedingung und wiederholt, bis sie gilt - der Inhalt läuft mindestens einmal.
blk.help.wait = Hält dieses Skript einige Sekunden an, danach geht es mit dem nächsten Block weiter (auch in Schleifen).
+7
View File
@@ -1332,3 +1332,10 @@ demo.m2.title = Rolled Anew
demo.m2.story = This map is rolled anew for every run of the campaign. Defeat every hero.
demo.m2.start = A random map. Even I have never seen it before.
demo.m2.wave = Two minutes are over: the heroes come.
blk.until = Repeat until
blk.dountil = Repeat, then check: until
blk.wait = Wait
blk.w_seconds = seconds
blk.help.until = Runs the blocks inside again and again until the condition holds - checked before each run, so if it already holds the inside does not run at all.
blk.help.dountil = Runs the blocks inside, then checks the condition and repeats until it holds - the inside always runs at least once.
blk.help.wait = Pauses this script for some seconds, then it goes on with the next block (also inside loops).
+7
View File
@@ -1332,3 +1332,10 @@ net.coop_editor = Editor campaign
net.coop_builtin = Built-in campaign
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
blk.until = Repetir hasta
blk.dountil = Repetir y luego: hasta
blk.wait = Esperar
blk.w_seconds = segundos
blk.help.until = Ejecuta los bloques de dentro una y otra vez hasta que se cumpla la condición; se comprueba antes de cada vuelta, así que si ya se cumple no se ejecuta nada.
blk.help.dountil = Ejecuta los bloques de dentro, luego comprueba la condición y repite hasta que se cumpla; el contenido se ejecuta al menos una vez.
blk.help.wait = Detiene este script unos segundos y luego sigue con el siguiente bloque (también dentro de bucles).
+7
View File
@@ -1332,3 +1332,10 @@ net.coop_editor = Editor campaign
net.coop_builtin = Built-in campaign
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
blk.until = Ripeti finché
blk.dountil = Ripeti, poi controlla: finché
blk.wait = Attendi
blk.w_seconds = secondi
blk.help.until = Esegue i blocchi interni più volte finché la condizione è vera; viene controllata prima di ogni giro, quindi se è già vera il contenuto non viene eseguito.
blk.help.dountil = Esegue i blocchi interni, poi controlla la condizione e ripete finché è vera; il contenuto viene eseguito almeno una volta.
blk.help.wait = Mette in pausa questo script per alcuni secondi, poi prosegue con il blocco successivo (anche nei cicli).
+7
View File
@@ -1332,3 +1332,10 @@ net.coop_editor = Editor campaign
net.coop_builtin = Built-in campaign
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
blk.until = Повторять, пока не
blk.dountil = Повторять, затем проверить: пока не
blk.wait = Ждать
blk.w_seconds = сек.
blk.help.until = Снова и снова выполняет блоки внутри, пока условие не станет верным; проверка перед каждым проходом, поэтому если оно уже верно, блоки не выполняются.
blk.help.dountil = Выполняет блоки внутри, затем проверяет условие и повторяет, пока оно не станет верным; блоки выполняются хотя бы один раз.
blk.help.wait = Приостанавливает этот скрипт на несколько секунд, затем выполняется следующий блок (и внутри циклов).
+22 -147
View File
@@ -1,5 +1,5 @@
// Mission scripts as blocks: the block editor builds these trees, they are stored in the
// campaign file and translated into Rhai (internally only; the player never sees code).
// campaign file and run directly from the blocks (see mscript.rs); the player never sees code.
//
// A script is a list of statement blocks. Values (numbers, yes/no conditions) are small
// expression trees. Every block has a fixed shape, so the editor can draw and check it.
@@ -32,6 +32,9 @@ pub enum Block {
AddVar { name: String, value: Val },
If { cond: Val, then: Vec<Block>, #[serde(default)] els: Vec<Block> },
Repeat { times: Val, body: Vec<Block> },
Until { cond: Val, body: Vec<Block> }, // repeat until: checked before each run
DoUntil { cond: Val, body: Vec<Block> }, // repeat ... until: checked after each run
Wait { secs: Val },
Win,
Lose,
Reveal { zone: String },
@@ -141,7 +144,7 @@ pub const NUM_VALUES: [&str; 9] = ["num", "var", "gold", "mana", "time", "creatu
pub const BOOL_VALUES: [&str; 6] = ["cmp", "and", "or", "not", "bool", "eventkind"];
// statement blocks in the palette: (category, kind)
pub const PALETTE: [(&str, &str); 18] = [
pub const PALETTE: [(&str, &str); 21] = [
("say", "message"),
("say", "objective"),
("say", "hint"),
@@ -155,6 +158,9 @@ pub const PALETTE: [(&str, &str); 18] = [
("world", "mana"),
("logic", "if"),
("logic", "repeat"),
("logic", "until"),
("logic", "dountil"),
("logic", "wait"),
("logic", "trigger"),
("vars", "setvar"),
("vars", "addvar"),
@@ -181,6 +187,9 @@ impl Block {
"mana" => Block::Mana { amount: n(100.0) },
"if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() },
"repeat" => Block::Repeat { times: n(3.0), body: Vec::new() },
"until" => Block::Until { cond: Val::template("cmp").unwrap(), body: Vec::new() },
"dountil" => Block::DoUntil { cond: Val::template("cmp").unwrap(), body: Vec::new() },
"wait" => Block::Wait { secs: n(5.0) },
"trigger" => Block::Trigger { name: String::new(), on: true },
"setvar" => Block::SetVar { name: String::new(), value: n(0.0) },
"addvar" => Block::AddVar { name: String::new(), value: n(1.0) },
@@ -205,6 +214,9 @@ impl Block {
Block::Mana { .. } => "mana",
Block::If { .. } => "if",
Block::Repeat { .. } => "repeat",
Block::Until { .. } => "until",
Block::DoUntil { .. } => "dountil",
Block::Wait { .. } => "wait",
Block::Trigger { .. } => "trigger",
Block::SetVar { .. } => "setvar",
Block::AddVar { .. } => "addvar",
@@ -305,6 +317,13 @@ fn check_list(list: &[Block], n: &Names, path: &mut Vec<usize>, out: &mut Vec<Pr
check_list(body, n, path, out);
path.pop();
}
Block::Until { cond, body } | Block::DoUntil { cond, body } => {
check_val(cond, Ty::Bool, n, path, out);
path.push(0);
check_list(body, n, path, out);
path.pop();
}
Block::Wait { secs } => check_val(secs, Ty::Num, n, path, out),
Block::Reveal { zone } | Block::Camera { zone } | Block::Kill { zone, .. } if !zone_ok(zone) => out.push(p("blk.err_zone", path)),
Block::Terrain { zone, kind } => {
if !zone_ok(zone) {
@@ -345,160 +364,16 @@ pub fn vars_set(list: &[Block], out: &mut Vec<String>) {
vars_set(then, out);
vars_set(els, out);
}
Block::Repeat { body, .. } => vars_set(body, out),
Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => vars_set(body, out),
_ => {}
}
}
}
// ------------------------------------------------------------------ Rhai code
fn q(s: &str) -> String {
let mut o = String::from("\"");
for c in s.chars() {
match c {
'"' => o.push_str("\\\""),
'\\' => o.push_str("\\\\"),
'\n' => o.push_str("\\n"),
'\r' | '\t' => o.push(' '),
c => o.push(c),
}
}
o.push('"');
o
}
fn f(n: f64) -> String {
if n.is_finite() {
format!("{:?}", n) // always with a decimal point: Rhai keeps it a float
} else {
"0.0".into()
}
}
pub fn val_code(v: &Val) -> String {
match v {
Val::Num { n } => f(*n),
Val::Bool { b } => b.to_string(),
Val::Var { name } => format!("m.get({})", q(name)),
Val::Gold => "m.gold()".into(),
Val::Mana => "m.mana()".into(),
Val::Time => "m.time()".into(),
Val::Creatures { side, kind } => format!("m.count({}, {})", side, q(kind)),
Val::InZone { zone, side, kind } => format!("m.in_zone({}, {}, {})", q(zone), side, q(kind)),
Val::Random { a, b } => format!("m.random({}, {})", val_code(a), val_code(b)),
Val::Math { op, a, b } => {
let op = if MATH_OPS.contains(&op.as_str()) { op.as_str() } else { "+" };
if op == "/" {
format!("m.div({}, {})", val_code(a), val_code(b))
} else {
format!("({} {} {})", val_code(a), op, val_code(b))
}
}
Val::Cmp { op, a, b } => {
let op = match op.as_str() {
"=" => "==",
o if CMP_OPS.contains(&o) => o,
_ => ">",
};
format!("({} {} {})", val_code(a), op, val_code(b))
}
Val::And { a, b } => format!("({} && {})", val_code(a), val_code(b)),
Val::Or { a, b } => format!("({} || {})", val_code(a), val_code(b)),
Val::Not { a } => format!("(!{})", val_code(a)),
Val::EventKind { kind } => format!("m.event_is({})", q(kind)),
}
}
fn cond_code(v: &Val) -> String {
if v.ty() == Ty::Bool {
val_code(v)
} else {
format!("({} != 0.0)", val_code(v))
}
}
fn list_code(list: &[Block], depth: usize, out: &mut String) {
let ind = " ".repeat(depth + 1);
for b in list {
let line = match b {
Block::Message { text } => format!("m.message({});", q(text)),
Block::Objective { text } => format!("m.objective({});", q(text)),
Block::Hint { text } => format!("m.hint({});", q(text)),
Block::Spawn { side, kind, count, level, place, name } => {
let (pk, pz) = match place {
Place::Zone(z) => ("zone", z.as_str()),
Place::Event => ("event", ""),
Place::Core => ("core", ""),
};
format!("m.spawn_unit({}, {}, {}, {}, {}, {}, {});", side, q(kind), val_code(count), val_code(level), q(pk), q(pz), q(name))
}
Block::Wave { count } => format!("m.wave({});", val_code(count)),
Block::Gold { amount } => format!("m.add_gold({});", val_code(amount)),
Block::Mana { amount } => format!("m.add_mana({});", val_code(amount)),
Block::SetVar { name, value } => format!("m.set({}, {});", q(name), val_code(value)),
Block::AddVar { name, value } => format!("m.set({}, m.get({}) + {});", q(name), q(name), val_code(value)),
Block::If { cond, then, els } => {
out.push_str(&format!("{}if {} {{\n", ind, cond_code(cond)));
list_code(then, depth + 1, out);
if !els.is_empty() {
out.push_str(&format!("{}}} else {{\n", ind));
list_code(els, depth + 1, out);
}
out.push_str(&format!("{}}}\n", ind));
continue;
}
Block::Repeat { times, body } => {
out.push_str(&format!("{}for _i{} in 0..m.times({}) {{\n", ind, depth, val_code(times)));
list_code(body, depth + 1, out);
out.push_str(&format!("{}}}\n", ind));
continue;
}
Block::Win => "m.win();".into(),
Block::Lose => "m.lose();".into(),
Block::Reveal { zone } => format!("m.reveal({});", q(zone)),
Block::Camera { zone } => format!("m.camera({});", q(zone)),
Block::Terrain { zone, kind } => format!("m.terrain({}, {});", q(zone), q(kind)),
Block::Kill { zone, side } => format!("m.kill({}, {});", q(zone), side),
Block::Trigger { name, on } => format!("m.trigger({}, {});", q(name), on),
};
out.push_str(&ind);
out.push_str(&line);
out.push('\n');
}
}
// a Rhai function `fn <name>(m) { ... }` for a list of blocks
pub fn function_code(name: &str, list: &[Block]) -> String {
let mut out = format!("fn {}(m) {{\n", name);
list_code(list, 0, &mut out);
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_become_rhai_code() {
let list = vec![
Block::Message { text: "Hello \"master\"".into() },
Block::If {
cond: Val::Cmp { op: "=".into(), a: Box::new(Val::Var { name: "wave".into() }), b: Box::new(Val::num(2.0)) },
then: vec![Block::Spawn { side: 1, kind: "Knight".into(), count: Val::num(3.0), level: Val::num(2.0), place: Place::Zone("gate".into()), name: String::new() }],
els: vec![Block::Win],
},
Block::Repeat { times: Val::num(2.0), body: vec![Block::AddVar { name: "wave".into(), value: Val::num(1.0) }] },
];
let code = function_code("t_0", &list);
assert!(code.contains("m.message(\"Hello \\\"master\\\"\");"), "{}", code);
assert!(code.contains("if (m.get(\"wave\") == 2.0) {"), "{}", code);
assert!(code.contains("m.spawn_unit(1, \"Knight\", 3.0, 2.0, \"zone\", \"gate\", \"\");"), "{}", code);
assert!(code.contains("} else {"));
assert!(code.contains("for _i0 in 0..m.times(2.0) {"));
}
#[test]
fn checking_finds_missing_zones_vars_and_wrong_types() {
let zones = vec!["gate".to_string()];
+8 -3
View File
@@ -85,7 +85,7 @@ fn list_mut<'a>(root: &'a mut Vec<Block>, lp: &[usize]) -> Option<&'a mut Vec<Bl
els
}
}
Block::Repeat { body, .. } => body,
Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body,
_ => return None,
};
k += 2;
@@ -432,7 +432,7 @@ impl BlockEdit {
// slots: upper half before this block, lower half after it
slots.push((Rect::new(x, y - CH() * 0.3, w, header.h * 0.5 + CH() * 0.3), lp.to_vec(), k));
slots.push((Rect::new(x, y + header.h * 0.5, w, header.h * 0.5), lp.to_vec(), k + 1));
let nested = matches!(list[k], Block::If { .. } | Block::Repeat { .. });
let nested = matches!(list[k], Block::If { .. } | Block::Repeat { .. } | Block::Until { .. } | Block::DoUntil { .. });
// the block: header row with its fields
draw_block_shape(header, col, selected, found);
if let Some(p) = problem {
@@ -479,7 +479,7 @@ impl BlockEdit {
els
}
}
Block::Repeat { body, .. } => body,
Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body,
_ => unreachable!(),
};
let start = y;
@@ -563,6 +563,11 @@ impl BlockEdit {
self.value(im, gfx, times, Ty::Num, path, 1, cx, y, ctx);
word(cx, &tr("blk.w_times"));
}
Block::Until { cond, .. } | Block::DoUntil { cond, .. } => self.value(im, gfx, cond, Ty::Bool, path, 1, cx, y, ctx),
Block::Wait { secs } => {
self.value(im, gfx, secs, Ty::Num, path, 1, cx, y, ctx);
word(cx, &tr("blk.w_seconds"));
}
Block::Win | Block::Lose => {}
Block::Reveal { zone } | Block::Camera { zone } => self.zone_dd(im, gfx, zone, path, 1, cx, y, ctx),
Block::Terrain { zone, kind } => {
+25 -3
View File
@@ -164,16 +164,38 @@ pub fn demo_campaign() -> CampaignDoc {
trig("vault_claimed", TriggerKind::ZoneClaimed { zone: "vault".into() }, true, vec![say("demo.vault"), Block::Gold { amount: n(500.0) }]),
trig("tunnel_dug", TriggerKind::ZoneDug { zone: "tunnel".into() }, true, vec![say("demo.tunnel")]),
trig("crowd", TriggerKind::ZoneCount { zone: "field".into(), side: 1, kind: String::new(), op: ">=".into(), n: 4.0 }, true, vec![say("demo.crowd"), Block::Camera { zone: "field".into() }]),
trig("reinforcements", TriggerKind::Timer { secs: 90.0, every: false }, true, vec![say("demo.wave"), Block::Spawn { side: 1, kind: "Archer".into(), count: n(2.0), level: n(2.0), place: Place::Zone("field".into()), name: String::new() }]),
// repeat until (checked first): one archer every 15 seconds until four heroes stand on the field
trig(
"reinforcements",
TriggerKind::Timer { secs: 90.0, every: false },
true,
vec![
say("demo.wave"),
Block::Until {
cond: cmp(">=", Val::InZone { zone: "field".into(), side: 1, kind: String::new() }, n(4.0)),
body: vec![Block::Spawn { side: 1, kind: "Archer".into(), count: n(1.0), level: n(2.0), place: Place::Zone("field".into()), name: String::new() }, Block::Wait { secs: n(15.0) }],
},
],
),
trig("clock", TriggerKind::Timer { secs: 30.0, every: true }, false, vec![Block::AddVar { name: "ticks".into(), value: n(1.0) }]),
trig("knight_falls", TriggerKind::UnitDies { side: 1, kind: String::new(), name: String::new() }, false, vec![Block::AddVar { name: "kills".into(), value: n(1.0) }, Block::Message { text: tr("demo.kills") }]),
trig("captain_falls", TriggerKind::UnitDies { side: 1, kind: String::new(), name: "captain".into() }, true, vec![say("demo.captain"), Block::Mana { amount: n(50.0) }]),
trig("captain_falls", TriggerKind::UnitDies { side: 1, kind: String::new(), name: "captain".into() }, true, vec![say("demo.captain"), Block::Repeat { times: n(5.0), body: vec![Block::Mana { amount: n(10.0) }] }]),
trig("tower_down", TriggerKind::TowerFalls { side: 1 }, false, vec![say("demo.tower"), Block::Gold { amount: Val::Random { a: Box::new(n(100.0)), b: Box::new(n(300.0)) } }]),
trig("rich", TriggerKind::Value { what: "gold".into(), var: String::new(), op: ">=".into(), n: 8000.0 }, true, vec![say("demo.rich")]),
trig("lair", TriggerKind::RoomBuilt { room: "Lair".into() }, true, vec![Block::Hint { text: tr("demo.lair") }]),
trig("spell", TriggerKind::SpellCast { spell: String::new() }, false, vec![Block::Mana { amount: n(10.0) }]),
trig("slap", TriggerKind::Slap, true, vec![say("demo.slap")]),
trig("research", TriggerKind::Research, true, vec![say("demo.research"), Block::Repeat { times: n(2.0), body: vec![Block::Gold { amount: n(100.0) }] }]),
// repeat ... until (checked after): a little gold three times, a second apart
trig(
"research",
TriggerKind::Research,
true,
vec![
say("demo.research"),
Block::SetVar { name: "bonus".into(), value: n(0.0) },
Block::DoUntil { cond: cmp(">=", var("bonus"), n(3.0)), body: vec![Block::Gold { amount: n(100.0) }, Block::AddVar { name: "bonus".into(), value: n(1.0) }, Block::Wait { secs: n(1.0) }] },
],
),
];
doc.missions.push(m);
+353 -187
View File
@@ -1,24 +1,23 @@
// Mission scripts at run time: the block scripts of a mission (campaign_doc::MissionScripts)
// become one Rhai program; the triggers are checked ten times a second inside the fixed
// simulation step, so a co-op match runs them identically on every computer.
//
// The program never touches the game directly: reading functions (m.gold(), m.in_zone(..))
// look at the game through a pointer that is valid while a script runs, and everything a
// script does is collected as actions that are applied afterwards.
// are run directly from their blocks inside the fixed simulation step, so a co-op match runs
// them identically on every computer. Every run of a script is a small "thread" (where it is
// in which list of blocks); "wait" parks a thread until its time has come, and threads are
// saved with the game. Loops without a wait run up to a budget of steps per check and go on at
// the next check, so a script can never hang the game.
use std::cell::RefCell;
use std::collections::BTreeMap;
use rhai::{Engine, Scope, AST};
use serde::{Deserialize, Serialize};
use crate::blocks::function_code;
use crate::blocks::{Block, Place, Val};
use crate::campaign_doc::{MissionScripts, TriggerKind, ZoneDoc};
use crate::game::*;
use crate::map::*;
const CHECK_STEPS: u64 = 5; // triggers are checked every 5 fixed steps (0.1 s)
const MAX_LOG: usize = 60;
const STEP_BUDGET: u32 = 2000; // blocks a thread may run per check before it yields
const MAX_THREADS: usize = 64;
// pending events the simulation reports (the rest is found by comparing two checks)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -59,10 +58,40 @@ pub struct ScriptState {
next_second: f32,
rng: u32,
pub error: Option<String>,
#[serde(default)]
threads: Vec<Thread>, // scripts that are waiting (or yielded in a long loop)
#[serde(skip)]
pub camera: Option<(f32, f32)>, // a block asked the camera to look here (taken by main)
}
// which script a thread runs
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
enum Src {
Start,
Victory,
Defeat,
Tick,
Trigger(usize),
}
// a list of blocks being worked through: its path (pairs of block index and branch below the
// script's own list), the next block, and what kind of list it is
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Frame {
lp: Vec<usize>,
idx: usize,
left: i64, // repeat: runs still to come after this one (-1: not a repeat)
loop_kind: u8, // 0 plain, 1 repeat, 2 repeat until (checked before), 3 repeat ... until (checked after)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Thread {
src: Src,
frames: Vec<Frame>,
wake: f32,
ev: EventInfo,
}
// what a running script asks for
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -83,44 +112,12 @@ enum Act {
}
// the triggering unit (for "where the unit is" and "the unit is a ...")
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct EventInfo {
kind: Option<EK>,
pos: Option<(f32, f32)>,
}
struct Ctx {
game: *const Game,
vars: BTreeMap<String, f64>,
acts: Vec<Act>,
event: EventInfo,
rng: u32,
}
thread_local! {
static CTX: RefCell<Ctx> = RefCell::new(Ctx { game: std::ptr::null(), vars: BTreeMap::new(), acts: Vec::new(), event: EventInfo::default(), rng: 1 });
// the compiled program of the running mission (key: the program text)
static PROGRAM: RefCell<Option<(String, Engine, AST)>> = RefCell::new(None);
}
// reads the game while a script runs (the pointer is set by run_fn around every call)
fn with_game<R>(f: impl FnOnce(&Game) -> R, fallback: R) -> R {
CTX.with(|c| {
let p = c.borrow().game;
if p.is_null() {
fallback
} else {
// SAFETY: run_fn sets the pointer from a live &Game for the duration of the call
// and clears it afterwards; scripts run on this thread only
f(unsafe { &*p })
}
})
}
fn act(a: Act) {
CTX.with(|c| c.borrow_mut().acts.push(a));
}
pub fn kind_by_name(name: &str) -> Option<EK> {
let n = name.trim().to_lowercase();
crate::blocks::KINDS.iter().position(|k| k.to_lowercase() == n).map(|i| match i {
@@ -247,118 +244,41 @@ fn line_side(z: &ZoneDoc, x: f32, y: f32) -> i8 {
}
}
// ------------------------------------------------------------------ the program
// the Rhai program of a mission: one function per script and trigger
pub fn program(s: &MissionScripts) -> String {
let mut code = String::new();
code.push_str(&function_code("s_start", &s.start));
code.push_str(&function_code("s_victory", &s.victory));
code.push_str(&function_code("s_defeat", &s.defeat));
code.push_str(&function_code("s_tick", &s.tick));
for (k, t) in s.triggers.iter().enumerate() {
code.push_str(&function_code(&format!("t_{}", k), &t.script));
}
code
}
#[derive(Clone)]
struct M;
fn engine() -> Engine {
let mut e = Engine::new();
e.set_max_operations(100_000);
e.set_max_call_levels(16);
e.register_type_with_name::<M>("mission");
// reading
e.register_fn("gold", |_: &mut M| with_game(|g| g.gold as f64, 0.0));
e.register_fn("mana", |_: &mut M| with_game(|g| g.mana as f64, 0.0));
e.register_fn("time", |_: &mut M| with_game(|g| g.time as f64, 0.0));
e.register_fn("get", |_: &mut M, name: &str| CTX.with(|c| c.borrow().vars.get(name).copied().unwrap_or(0.0)));
e.register_fn("set", |_: &mut M, name: &str, v: f64| {
CTX.with(|c| {
c.borrow_mut().vars.insert(name.to_string(), if v.is_finite() { v } else { 0.0 });
})
});
e.register_fn("count", |_: &mut M, side: i64, kind: &str| {
with_game(|g| g.ents.iter().filter(|e| e.alive && e.captive == 0 && !e.chained && matches(e.side(), e.kind, side as i32, kind)).count() as f64, 0.0)
});
e.register_fn("in_zone", |_: &mut M, zone: &str, side: i64, kind: &str| {
with_game(
|g| {
let Some(z) = g.script.as_ref().and_then(|s| s.zones.iter().find(|z| z.name == zone)) else { return 0.0 };
g.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, side as i32, kind) && zone_contains(z, e.x, e.y)).count() as f64
},
0.0,
)
});
e.register_fn("random", |_: &mut M, a: f64, b: f64| {
CTX.with(|c| {
let mut c = c.borrow_mut();
// xorshift: deterministic, part of the saved script state
let mut x = c.rng.max(1);
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
c.rng = x;
let (lo, hi) = (a.min(b).round(), a.max(b).round());
lo + (x as f64 % (hi - lo + 1.0)).floor()
})
});
e.register_fn("div", |_: &mut M, a: f64, b: f64| if b == 0.0 { 0.0 } else { a / b });
e.register_fn("times", |_: &mut M, n: f64| if n.is_finite() { (n.round() as i64).clamp(0, 100) } else { 0 });
e.register_fn("event_is", |_: &mut M, kind: &str| CTX.with(|c| c.borrow().event.kind.is_some() && c.borrow().event.kind == kind_by_name(kind)));
// doing
e.register_fn("message", |_: &mut M, s: &str| act(Act::Message(s.to_string())));
e.register_fn("objective", |_: &mut M, s: &str| act(Act::Objective(s.to_string())));
e.register_fn("hint", |_: &mut M, s: &str| act(Act::Hint(s.to_string())));
e.register_fn("spawn_unit", |_: &mut M, side: i64, kind: &str, count: f64, level: f64, place: &str, zone: &str, name: &str| {
act(Act::Spawn { side: side as i32, kind: kind.to_string(), count: count.round() as i64, level: level.round() as i64, place: place.to_string(), zone: zone.to_string(), name: name.to_string() })
});
e.register_fn("wave", |_: &mut M, n: f64| act(Act::Wave(n.round() as i64)));
e.register_fn("add_gold", |_: &mut M, n: f64| act(Act::Gold(n)));
e.register_fn("add_mana", |_: &mut M, n: f64| act(Act::Mana(n)));
e.register_fn("win", |_: &mut M| act(Act::Win));
e.register_fn("lose", |_: &mut M| act(Act::Lose));
e.register_fn("reveal", |_: &mut M, z: &str| act(Act::Reveal(z.to_string())));
e.register_fn("camera", |_: &mut M, z: &str| act(Act::Camera(z.to_string())));
e.register_fn("terrain", |_: &mut M, z: &str, k: &str| act(Act::Terrain(z.to_string(), k.to_string())));
e.register_fn("kill", |_: &mut M, z: &str, side: i64| act(Act::Kill(z.to_string(), side as i32)));
e.register_fn("trigger", |_: &mut M, name: &str, on: bool| act(Act::Trigger(name.to_string(), on)));
e
}
// runs one function of the mission's program; returns the actions or an error
fn run_fn(g: &Game, code: &str, name: &str, vars: &mut BTreeMap<String, f64>, rng: &mut u32, event: EventInfo) -> Result<Vec<Act>, String> {
PROGRAM.with(|p| {
let mut p = p.borrow_mut();
if p.as_ref().map_or(true, |(c, _, _)| c != code) {
let e = engine();
let ast = e.compile(code).map_err(|e| e.to_string())?;
*p = Some((code.to_string(), e, ast));
}
let (_, e, ast) = p.as_ref().unwrap();
CTX.with(|c| {
let mut c = c.borrow_mut();
c.game = g as *const Game;
c.vars = std::mem::take(vars);
c.acts.clear();
c.event = event;
c.rng = *rng;
});
let mut scope = Scope::new();
let r = e.call_fn::<rhai::Dynamic>(&mut scope, ast, name, (M,));
CTX.with(|c| {
let mut c = c.borrow_mut();
c.game = std::ptr::null();
*vars = std::mem::take(&mut c.vars);
*rng = c.rng;
match r {
Ok(_) => Ok(std::mem::take(&mut c.acts)),
Err(err) => Err(err.to_string()),
// the list of blocks at a path inside a script
fn list_at<'a>(s: &'a MissionScripts, src: Src, lp: &[usize]) -> Option<&'a [Block]> {
let mut cur: &[Block] = match src {
Src::Start => &s.start,
Src::Victory => &s.victory,
Src::Defeat => &s.defeat,
Src::Tick => &s.tick,
Src::Trigger(k) => &s.triggers.get(k)?.script,
};
let mut k = 0;
while k + 1 < lp.len() + 1 && k < lp.len() {
let (i, branch) = (lp[k], *lp.get(k + 1)?);
cur = match cur.get(i)? {
Block::If { then, els, .. } => {
if branch == 0 {
then
} else {
els
}
}
})
})
Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body,
_ => return None,
};
k += 2;
}
Some(cur)
}
fn xorshift(rng: &mut u32) -> u32 {
let mut x = (*rng).max(1);
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*rng = x;
x
}
// ------------------------------------------------------------------ the game side
@@ -429,58 +349,240 @@ impl Game {
return;
}
let mut s = self.script.take().unwrap();
let code = program(&s.scripts);
let mut fire: Vec<(String, EventInfo, Option<usize>)> = Vec::new(); // function, event, trigger
let mut fire: Vec<(String, EventInfo, Option<usize>)> = Vec::new();
let mut start: Vec<(Src, EventInfo)> = Vec::new();
if !s.started {
s.started = true;
s.next_second = self.time + 1.0;
self.remember(&mut s, true);
fire.push(("s_start".into(), EventInfo::default(), None));
start.push((Src::Start, EventInfo::default()));
} else {
self.check_triggers(&mut s, &mut fire);
if self.time >= s.next_second {
s.next_second += 1.0;
for f in ["s_tick", "s_victory", "s_defeat"] {
fire.push((f.into(), EventInfo::default(), None));
// the checks run every second, but never twice at once (one may still be waiting)
for src in [Src::Tick, Src::Victory, Src::Defeat] {
if !s.threads.iter().any(|t| t.src == src) {
start.push((src, EventInfo::default()));
}
}
}
}
for (f, ev, trig) in fire {
if let Some(k) = trig {
// a trigger fires: once-triggers only the first time, switched-off ones never
if !s.enabled.get(k).copied().unwrap_or(false) || (s.scripts.triggers[k].once && s.done[k]) {
continue;
}
s.done[k] = true;
let name = s.scripts.triggers[k].name.clone();
s.log(self.time, name);
for (_, ev, trig) in fire {
let Some(k) = trig else { continue };
// a trigger fires: once-triggers only the first time, switched-off ones never
if !s.enabled.get(k).copied().unwrap_or(false) || (s.scripts.triggers[k].once && s.done[k]) {
continue;
}
let (mut vars, mut rng) = (std::mem::take(&mut s.vars), s.rng);
let r = run_fn(self, &code, &f, &mut vars, &mut rng, ev.clone());
s.vars = vars;
s.rng = rng;
match r {
Ok(acts) => {
for a in acts {
self.apply_act(&mut s, a, &ev);
}
}
Err(e) => {
if s.error.is_none() {
self.script_msg("msg.script_error", vec![e.chars().take(120).collect()]);
}
s.log(self.time, format!("! {}", e.chars().take(80).collect::<String>()));
s.error = Some(e);
}
}
if self.state != 0 {
break;
s.done[k] = true;
let name = s.scripts.triggers[k].name.clone();
s.log(self.time, name);
start.push((Src::Trigger(k), ev));
}
let mut threads = std::mem::take(&mut s.threads);
for (src, ev) in start {
threads.push(Thread { src, frames: vec![Frame { lp: Vec::new(), idx: 0, left: -1, loop_kind: 0 }], wake: 0.0, ev });
}
// run every thread whose time has come; the waiting ones stay
let scripts = std::mem::take(&mut s.scripts);
let mut keep = Vec::new();
for mut t in threads {
if self.state == 0 && t.wake <= self.time && !self.run_thread(&mut s, &scripts, &mut t) {
keep.push(t);
} else if t.wake > self.time {
keep.push(t);
}
}
if keep.len() > MAX_THREADS {
keep.drain(0..keep.len() - MAX_THREADS);
}
s.scripts = scripts;
s.threads = keep;
s.events.clear();
self.script = Some(s);
}
// runs a thread until it ends (true), waits or used up its steps (false)
fn run_thread(&mut self, s: &mut ScriptState, scripts: &MissionScripts, t: &mut Thread) -> bool {
let mut budget = STEP_BUDGET;
loop {
if self.state != 0 {
return true;
}
if budget == 0 {
t.wake = self.time; // go on at the next check
return false;
}
budget -= 1;
let Some(fr) = t.frames.last() else { return true };
let Some(list) = list_at(scripts, t.src, &fr.lp) else { return true };
let (idx, lp, left, loop_kind) = (fr.idx, fr.lp.clone(), fr.left, fr.loop_kind);
if idx >= list.len() {
// the end of a list: loop again, or back to the list around it
let again = match loop_kind {
1 => left > 0,
2 | 3 => {
// the loop's condition sits in its block in the list around it
let outer = list_at(scripts, t.src, &lp[..lp.len() - 2]).and_then(|l| l.get(lp[lp.len() - 2]));
match outer {
Some(Block::Until { cond, .. }) | Some(Block::DoUntil { cond, .. }) => !self.cond(s, cond, &t.ev),
_ => false,
}
}
_ => false,
};
if again {
let fr = t.frames.last_mut().unwrap();
fr.idx = 0;
if loop_kind == 1 {
fr.left -= 1;
}
continue;
}
t.frames.pop();
match t.frames.last_mut() {
Some(parent) => parent.idx += 1,
None => return true,
}
continue;
}
let inner = |branch: usize, left: i64, kind: u8| {
let mut p = lp.clone();
p.push(idx);
p.push(branch);
Frame { lp: p, idx: 0, left, loop_kind: kind }
};
match &list[idx] {
Block::If { cond, .. } => {
let c = self.cond(s, cond, &t.ev);
t.frames.push(inner(if c { 0 } else { 1 }, -1, 0));
}
Block::Repeat { times, .. } => {
let n = self.eval(s, times, &t.ev).round().clamp(0.0, 100.0) as i64;
if n > 0 {
t.frames.push(inner(0, n - 1, 1));
} else {
t.frames.last_mut().unwrap().idx += 1;
}
}
Block::Until { cond, .. } => {
// checked before each run: already true means the inside never runs
if self.cond(s, cond, &t.ev) {
t.frames.last_mut().unwrap().idx += 1;
} else {
t.frames.push(inner(0, -1, 2));
}
}
Block::DoUntil { .. } => t.frames.push(inner(0, -1, 3)),
Block::Wait { secs } => {
let n = self.eval(s, secs, &t.ev).clamp(0.1, 36000.0) as f32;
t.frames.last_mut().unwrap().idx += 1;
t.wake = self.time + n;
return false;
}
b => {
let b = b.clone();
t.frames.last_mut().unwrap().idx += 1;
self.exec(s, &b, &t.ev);
}
}
}
}
// a value
fn eval(&self, s: &mut ScriptState, v: &Val, ev: &EventInfo) -> f64 {
let r = match v {
Val::Num { n } => *n,
Val::Bool { b } => *b as i32 as f64,
Val::Var { name } => s.vars.get(name).copied().unwrap_or(0.0),
Val::Gold => self.gold as f64,
Val::Mana => self.mana as f64,
Val::Time => self.time as f64,
Val::Creatures { side, kind } => self.ents.iter().filter(|e| e.alive && e.captive == 0 && !e.chained && matches(e.side(), e.kind, *side, kind)).count() as f64,
Val::InZone { zone, side, kind } => match s.zone(zone) {
Some(z) => self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(z, e.x, e.y)).count() as f64,
None => 0.0,
},
Val::Random { a, b } => {
let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev));
let (lo, hi) = (a.min(b).round(), a.max(b).round());
lo + (xorshift(&mut s.rng) as f64 % (hi - lo + 1.0)).floor()
}
Val::Math { op, a, b } => {
let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev));
match op.as_str() {
"-" => a - b,
"*" => a * b,
"/" => {
if b == 0.0 {
0.0
} else {
a / b
}
}
_ => a + b,
}
}
Val::Cmp { op, a, b } => {
let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev));
cmp(op, a, b) as i32 as f64
}
Val::And { a, b } => (self.cond(s, a, ev) && self.cond(s, b, ev)) as i32 as f64,
Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64,
Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64,
Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64,
};
if r.is_finite() {
r
} else {
0.0
}
}
fn cond(&self, s: &mut ScriptState, v: &Val, ev: &EventInfo) -> bool {
self.eval(s, v, ev) != 0.0
}
// one block that does something
fn exec(&mut self, s: &mut ScriptState, b: &Block, ev: &EventInfo) {
let act = match b {
Block::Message { text } => Act::Message(text.clone()),
Block::Objective { text } => Act::Objective(text.clone()),
Block::Hint { text } => Act::Hint(text.clone()),
Block::Spawn { side, kind, count, level, place, name } => {
let (pk, pz) = match place {
Place::Zone(z) => ("zone", z.clone()),
Place::Event => ("event", String::new()),
Place::Core => ("core", String::new()),
};
Act::Spawn { side: *side, kind: kind.clone(), count: self.eval(s, count, ev).round() as i64, level: self.eval(s, level, ev).round() as i64, place: pk.into(), zone: pz, name: name.clone() }
}
Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64),
Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)),
Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)),
Block::SetVar { name, value } => {
let v = self.eval(s, value, ev);
s.vars.insert(name.clone(), v);
return;
}
Block::AddVar { name, value } => {
let v = s.vars.get(name).copied().unwrap_or(0.0) + self.eval(s, value, ev);
s.vars.insert(name.clone(), v);
return;
}
Block::Win => Act::Win,
Block::Lose => Act::Lose,
Block::Reveal { zone } => Act::Reveal(zone.clone()),
Block::Camera { zone } => Act::Camera(zone.clone()),
Block::Terrain { zone, kind } => Act::Terrain(zone.clone(), kind.clone()),
Block::Kill { zone, side } => Act::Kill(zone.clone(), *side),
Block::Trigger { name, on } => Act::Trigger(name.clone(), *on),
// the flow blocks are handled by run_thread
Block::If { .. } | Block::Repeat { .. } | Block::Until { .. } | Block::DoUntil { .. } | Block::Wait { .. } => return,
};
self.apply_act(s, act, ev);
}
// what the next check compares against: living units, towers, rooms, research, zone counts
fn remember(&self, s: &mut ScriptState, first: bool) {
s.alive = self.ents.iter().filter(|e| e.alive && e.captive == 0).map(|e| Seen { id: e.id, kind: e.kind, side: e.side(), x: e.x, y: e.y }).collect();
@@ -903,6 +1005,70 @@ mod tests {
assert_eq!(v.get("rich"), Some(&1.0));
}
#[test]
fn waits_and_both_kinds_of_until_loops() {
let mut g = Game::new(3, 64, 64);
let cmp = |op: &str, a: Val, b: f64| Val::Cmp { op: op.into(), a: Box::new(a), b: Box::new(Val::num(b)) };
let var = |n: &str| Val::Var { name: n.into() };
let scripts = MissionScripts {
start: vec![
Block::SetVar { name: "a".into(), value: Val::num(0.0) },
Block::SetVar { name: "pre".into(), value: Val::num(0.0) },
Block::SetVar { name: "post".into(), value: Val::num(0.0) },
// repeat until (checked before): already true, so the inside never runs
Block::Until { cond: cmp(">=", var("a"), 0.0), body: vec![Block::AddVar { name: "pre".into(), value: Val::num(1.0) }] },
// repeat ... until (checked after): runs once even though it is already true
Block::DoUntil { cond: cmp(">=", var("a"), 0.0), body: vec![Block::AddVar { name: "post".into(), value: Val::num(1.0) }] },
// count to three, one step every second
Block::Until {
cond: cmp(">=", var("a"), 3.0),
body: vec![Block::Wait { secs: Val::num(1.0) }, Block::AddVar { name: "a".into(), value: Val::num(1.0) }],
},
Block::SetVar { name: "done".into(), value: Val::num(1.0) },
],
..Default::default()
};
g.script = Some(ScriptState::new(scripts, vec![], 7));
run(&mut g, 0.3);
let v = |g: &Game, k: &str| g.script.as_ref().unwrap().vars.get(k).copied();
assert_eq!(v(&g, "pre"), Some(0.0), "repeat until checks first");
assert_eq!(v(&g, "post"), Some(1.0), "repeat ... until runs once");
assert_eq!(v(&g, "a"), Some(0.0), "waiting");
assert_eq!(v(&g, "done"), None);
run(&mut g, 1.5);
assert_eq!(v(&g, "a"), Some(1.0), "one step after a second");
run(&mut g, 2.2);
assert_eq!(v(&g, "a"), Some(3.0));
assert_eq!(v(&g, "done"), Some(1.0), "the script went on after the loop");
// a waiting script survives saving and loading
let mut g2 = Game::new(3, 64, 64);
g2.script = Some(ScriptState::new(
MissionScripts { start: vec![Block::Wait { secs: Val::num(2.0) }, Block::SetVar { name: "late".into(), value: Val::num(1.0) }], ..Default::default() },
vec![],
1,
));
run(&mut g2, 0.5);
let json = serde_json::to_string(&g2).unwrap();
let mut g3: Game = serde_json::from_str(&json).unwrap();
g3.map.activate();
run(&mut g3, 2.0);
assert_eq!(g3.script.as_ref().unwrap().vars.get("late"), Some(&1.0));
}
#[test]
fn an_endless_loop_does_not_hang_the_game() {
let mut g = Game::new(3, 64, 64);
let scripts = MissionScripts {
start: vec![Block::Until { cond: Val::Bool { b: false }, body: vec![Block::AddVar { name: "n".into(), value: Val::num(1.0) }] }],
..Default::default()
};
g.script = Some(ScriptState::new(scripts, vec![], 7));
run(&mut g, 1.0);
let n = g.script.as_ref().unwrap().vars["n"];
assert!(n > 100.0, "the loop runs: {}", n);
assert!(g.time > 0.9, "and the game goes on");
}
#[test]
fn zone_shapes() {
let r = rect("r", (2.0, 2.0), (4.0, 3.0));