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
+453 -673

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 ## 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. - 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 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).
- The Rust Rhai dependency must keep `no_time` enabled in `Cargo.toml` so the WASM output stays loadable by the macroquad JS bundle. - Script runs are threads (resumable after "wait", saved with the game); loops have a step budget per check so nothing can hang.
- Rhai reserves `spawn`; the internal API uses `spawn_unit`.
- Per mission: special scripts (start, victory check, defeat check, clock) and triggers (`TriggerKind` in `src/campaign_doc.rs`). - 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. - 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`. - Unknown creature names, zones, triggers and unset variables are reported by `blocks::check`.
- Reference: `docs/CAMPAIGN_EDITOR.md` and `docs/MISSION_SCRIPTING.md`. - 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -39,18 +26,6 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 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]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.2" version = "1.25.2"
@@ -63,15 +38,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.5" version = "1.0.5"
@@ -84,39 +50,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" 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]] [[package]]
name = "core_maths" name = "core_maths"
version = "0.1.1" version = "0.1.1"
@@ -135,18 +68,11 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "dungeoncraft" name = "dungeoncraft"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"macroquad", "macroquad",
"rhai",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -193,41 +119,6 @@ dependencies = [
"ttf-parser", "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]] [[package]]
name = "glam" name = "glam"
version = "0.27.0" version = "0.27.0"
@@ -264,17 +155,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 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]] [[package]]
name = "libc" name = "libc"
version = "0.2.191" version = "0.2.191"
@@ -378,40 +258,19 @@ dependencies = [
"malloc_buf", "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]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags",
"crc32fast", "crc32fast",
"fdeflate", "fdeflate",
"flate2", "flate2",
"miniz_oxide 0.8.9", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@@ -436,41 +295,6 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
@@ -498,7 +322,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.9", "syn",
] ]
[[package]] [[package]]
@@ -520,46 +344,6 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" 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]] [[package]]
name = "syn" name = "syn"
version = "3.0.9" version = "3.0.9"
@@ -571,21 +355,6 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@@ -601,72 +370,6 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" 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]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -689,26 +392,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
-1
View File
@@ -6,7 +6,6 @@ license = "MIT"
[dependencies] [dependencies]
macroquad = { version = "0.4" } macroquad = { version = "0.4" }
rhai = { version = "1.27.0", default-features = false, features = ["std", "no_time"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+18 -22
View File
@@ -1,12 +1,12 @@
# Mission scripting with blocks # Mission scripting with blocks
Missions of editor campaigns are scripted with **blocks** (like Blockly): coloured puzzle pieces 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 put together in the block editor (see [CAMPAIGN_EDITOR.md](CAMPAIGN_EDITOR.md)). There is no code:
code – internally the blocks are translated into Rhai, compiled once per mission and run inside the the game runs the blocks directly, inside its fixed simulation step, so a co-op match runs them
game's fixed simulation step, so a co-op match runs them identically on every computer. identically on every computer.
Code: `src/blocks.rs` (blocks, values, checking, translation), `src/mscript.rs` (run time, triggers, Code: `src/blocks.rs` (blocks, values, checking), `src/mscript.rs` (run time, triggers, zones),
zones), `src/campaign_doc.rs` (`MissionScripts`, `TriggerDoc`, `TriggerKind`). `src/campaign_doc.rs` (`MissionScripts`, `TriggerDoc`, `TriggerKind`).
## Scripts of a mission ## Scripts of a mission
@@ -64,6 +64,9 @@ creatures and heroes.
| | Gold + / Mana + | amount (negative takes) | | | Gold + / Mana + | amount (negative takes) |
| Logic | If / else | condition; blocks inside | | Logic | If / else | condition; blocks inside |
| | Repeat | times (0–100); 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 | | | Switch trigger | trigger, on/off |
| Variables | Set | variable, value | | Variables | Set | variable, value |
| | Change | variable, value to add | | | Change | variable, value to add |
@@ -91,28 +94,21 @@ campaign file check (`CampaignDoc::validate`).
## At run time (`src/mscript.rs`) ## At run time (`src/mscript.rs`)
- The mission's program is generated by `mscript::program` – one Rhai function per script and - Every run of a script (the start, each second's checks, each fired trigger) is a **thread**: the
trigger (`s_start`, `s_victory`, `s_defeat`, `s_tick`, `t_0`, `t_1`, ...) – and compiled once. lists of blocks it is in and where. *Wait* parks a thread until its time; threads are saved with
- Scripts read the game (`m.gold()`, `m.count(side, kind)`, `m.in_zone(zone, side, kind)`, ...) the game, so a waiting script goes on after loading.
and collect actions (`m.message`, `m.spawn_unit`, `m.terrain`, `m.win`, ...), which are applied - The victory check, defeat check and clock start every second, but not while their previous run is
after the call. Variables, the random state, fired triggers and zone contents are part of still waiting. A trigger that fires again while its last run waits starts a second run.
`ScriptState` and saved with the game. - 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. - 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. - 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 ## The demo
*Open the demo* in the campaign editor: mission 1 ("The Trial of Triggers") uses all 14 trigger *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 circle, line, tiles); mission 2 is a random map behind a branch. Play it with *Test play* and watch
the debug view. 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.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.start = Eine Zufallskarte. Selbst ich habe sie noch nie gesehen.
demo.m2.wave = Zwei Minuten sind um: Die Helden kommen. 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.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.start = A random map. Even I have never seen it before.
demo.m2.wave = Two minutes are over: the heroes come. 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_builtin = Built-in campaign
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) 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) 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_builtin = Built-in campaign
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) 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) 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_builtin = Built-in campaign
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) 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) 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 // 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 // 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. // 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 }, AddVar { name: String, value: Val },
If { cond: Val, then: Vec<Block>, #[serde(default)] els: Vec<Block> }, If { cond: Val, then: Vec<Block>, #[serde(default)] els: Vec<Block> },
Repeat { times: Val, body: 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, Win,
Lose, Lose,
Reveal { zone: String }, 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"]; pub const BOOL_VALUES: [&str; 6] = ["cmp", "and", "or", "not", "bool", "eventkind"];
// statement blocks in the palette: (category, kind) // statement blocks in the palette: (category, kind)
pub const PALETTE: [(&str, &str); 18] = [ pub const PALETTE: [(&str, &str); 21] = [
("say", "message"), ("say", "message"),
("say", "objective"), ("say", "objective"),
("say", "hint"), ("say", "hint"),
@@ -155,6 +158,9 @@ pub const PALETTE: [(&str, &str); 18] = [
("world", "mana"), ("world", "mana"),
("logic", "if"), ("logic", "if"),
("logic", "repeat"), ("logic", "repeat"),
("logic", "until"),
("logic", "dountil"),
("logic", "wait"),
("logic", "trigger"), ("logic", "trigger"),
("vars", "setvar"), ("vars", "setvar"),
("vars", "addvar"), ("vars", "addvar"),
@@ -181,6 +187,9 @@ impl Block {
"mana" => Block::Mana { amount: n(100.0) }, "mana" => Block::Mana { amount: n(100.0) },
"if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() }, "if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() },
"repeat" => Block::Repeat { times: n(3.0), body: 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 }, "trigger" => Block::Trigger { name: String::new(), on: true },
"setvar" => Block::SetVar { name: String::new(), value: n(0.0) }, "setvar" => Block::SetVar { name: String::new(), value: n(0.0) },
"addvar" => Block::AddVar { name: String::new(), value: n(1.0) }, "addvar" => Block::AddVar { name: String::new(), value: n(1.0) },
@@ -205,6 +214,9 @@ impl Block {
Block::Mana { .. } => "mana", Block::Mana { .. } => "mana",
Block::If { .. } => "if", Block::If { .. } => "if",
Block::Repeat { .. } => "repeat", Block::Repeat { .. } => "repeat",
Block::Until { .. } => "until",
Block::DoUntil { .. } => "dountil",
Block::Wait { .. } => "wait",
Block::Trigger { .. } => "trigger", Block::Trigger { .. } => "trigger",
Block::SetVar { .. } => "setvar", Block::SetVar { .. } => "setvar",
Block::AddVar { .. } => "addvar", 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); check_list(body, n, path, out);
path.pop(); 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::Reveal { zone } | Block::Camera { zone } | Block::Kill { zone, .. } if !zone_ok(zone) => out.push(p("blk.err_zone", path)),
Block::Terrain { zone, kind } => { Block::Terrain { zone, kind } => {
if !zone_ok(zone) { if !zone_ok(zone) {
@@ -345,160 +364,16 @@ pub fn vars_set(list: &[Block], out: &mut Vec<String>) {
vars_set(then, out); vars_set(then, out);
vars_set(els, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn checking_finds_missing_zones_vars_and_wrong_types() { fn checking_finds_missing_zones_vars_and_wrong_types() {
let zones = vec!["gate".to_string()]; 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 els
} }
} }
Block::Repeat { body, .. } => body, Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body,
_ => return None, _ => return None,
}; };
k += 2; k += 2;
@@ -432,7 +432,7 @@ impl BlockEdit {
// slots: upper half before this block, lower half after it // 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 - 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)); 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 // the block: header row with its fields
draw_block_shape(header, col, selected, found); draw_block_shape(header, col, selected, found);
if let Some(p) = problem { if let Some(p) = problem {
@@ -479,7 +479,7 @@ impl BlockEdit {
els els
} }
} }
Block::Repeat { body, .. } => body, Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body,
_ => unreachable!(), _ => unreachable!(),
}; };
let start = y; let start = y;
@@ -563,6 +563,11 @@ impl BlockEdit {
self.value(im, gfx, times, Ty::Num, path, 1, cx, y, ctx); self.value(im, gfx, times, Ty::Num, path, 1, cx, y, ctx);
word(cx, &tr("blk.w_times")); 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::Win | Block::Lose => {}
Block::Reveal { zone } | Block::Camera { zone } => self.zone_dd(im, gfx, zone, path, 1, cx, y, ctx), Block::Reveal { zone } | Block::Camera { zone } => self.zone_dd(im, gfx, zone, path, 1, cx, y, ctx),
Block::Terrain { zone, kind } => { 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("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("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("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("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("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("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("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("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("spell", TriggerKind::SpellCast { spell: String::new() }, false, vec![Block::Mana { amount: n(10.0) }]),
trig("slap", TriggerKind::Slap, true, vec![say("demo.slap")]), 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); doc.missions.push(m);
+340 -174
View File
@@ -1,24 +1,23 @@
// Mission scripts at run time: the block scripts of a mission (campaign_doc::MissionScripts) // 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 // are run directly from their blocks inside the fixed simulation step, so a co-op match runs
// simulation step, so a co-op match runs them identically on every computer. // 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
// The program never touches the game directly: reading functions (m.gold(), m.in_zone(..)) // saved with the game. Loops without a wait run up to a budget of steps per check and go on at
// look at the game through a pointer that is valid while a script runs, and everything a // the next check, so a script can never hang the game.
// script does is collected as actions that are applied afterwards.
use std::cell::RefCell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use rhai::{Engine, Scope, AST};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::blocks::function_code; use crate::blocks::{Block, Place, Val};
use crate::campaign_doc::{MissionScripts, TriggerKind, ZoneDoc}; use crate::campaign_doc::{MissionScripts, TriggerKind, ZoneDoc};
use crate::game::*; use crate::game::*;
use crate::map::*; use crate::map::*;
const CHECK_STEPS: u64 = 5; // triggers are checked every 5 fixed steps (0.1 s) const CHECK_STEPS: u64 = 5; // triggers are checked every 5 fixed steps (0.1 s)
const MAX_LOG: usize = 60; 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) // pending events the simulation reports (the rest is found by comparing two checks)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -59,10 +58,40 @@ pub struct ScriptState {
next_second: f32, next_second: f32,
rng: u32, rng: u32,
pub error: Option<String>, pub error: Option<String>,
#[serde(default)]
threads: Vec<Thread>, // scripts that are waiting (or yielded in a long loop)
#[serde(skip)] #[serde(skip)]
pub camera: Option<(f32, f32)>, // a block asked the camera to look here (taken by main) 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 // what a running script asks for
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
enum Act { enum Act {
@@ -83,44 +112,12 @@ enum Act {
} }
// the triggering unit (for "where the unit is" and "the unit is a ...") // 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 { struct EventInfo {
kind: Option<EK>, kind: Option<EK>,
pos: Option<(f32, f32)>, 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> { pub fn kind_by_name(name: &str) -> Option<EK> {
let n = name.trim().to_lowercase(); let n = name.trim().to_lowercase();
crate::blocks::KINDS.iter().position(|k| k.to_lowercase() == n).map(|i| match i { 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 list of blocks at a path inside a script
fn list_at<'a>(s: &'a MissionScripts, src: Src, lp: &[usize]) -> Option<&'a [Block]> {
// the Rhai program of a mission: one function per script and trigger let mut cur: &[Block] = match src {
pub fn program(s: &MissionScripts) -> String { Src::Start => &s.start,
let mut code = String::new(); Src::Victory => &s.victory,
code.push_str(&function_code("s_start", &s.start)); Src::Defeat => &s.defeat,
code.push_str(&function_code("s_victory", &s.victory)); Src::Tick => &s.tick,
code.push_str(&function_code("s_defeat", &s.defeat)); Src::Trigger(k) => &s.triggers.get(k)?.script,
code.push_str(&function_code("s_tick", &s.tick)); };
for (k, t) in s.triggers.iter().enumerate() { let mut k = 0;
code.push_str(&function_code(&format!("t_{}", k), &t.script)); 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
} }
code }
Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body,
_ => return None,
};
k += 2;
}
Some(cur)
} }
#[derive(Clone)] fn xorshift(rng: &mut u32) -> u32 {
struct M; let mut x = (*rng).max(1);
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 << 13;
x ^= x >> 17; x ^= x >> 17;
x ^= x << 5; x ^= x << 5;
c.rng = x; *rng = x;
let (lo, hi) = (a.min(b).round(), a.max(b).round()); x
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 game side // ------------------------------------------------------------------ the game side
@@ -429,24 +349,27 @@ impl Game {
return; return;
} }
let mut s = self.script.take().unwrap(); let mut s = self.script.take().unwrap();
let code = program(&s.scripts); let mut fire: Vec<(String, EventInfo, Option<usize>)> = Vec::new();
let mut fire: Vec<(String, EventInfo, Option<usize>)> = Vec::new(); // function, event, trigger let mut start: Vec<(Src, EventInfo)> = Vec::new();
if !s.started { if !s.started {
s.started = true; s.started = true;
s.next_second = self.time + 1.0; s.next_second = self.time + 1.0;
self.remember(&mut s, true); self.remember(&mut s, true);
fire.push(("s_start".into(), EventInfo::default(), None)); start.push((Src::Start, EventInfo::default()));
} else { } else {
self.check_triggers(&mut s, &mut fire); self.check_triggers(&mut s, &mut fire);
if self.time >= s.next_second { if self.time >= s.next_second {
s.next_second += 1.0; s.next_second += 1.0;
for f in ["s_tick", "s_victory", "s_defeat"] { // the checks run every second, but never twice at once (one may still be waiting)
fire.push((f.into(), EventInfo::default(), None)); 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 { for (_, ev, trig) in fire {
let Some(k) = trig else { continue };
// a trigger fires: once-triggers only the first time, switched-off ones never // 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]) { if !s.enabled.get(k).copied().unwrap_or(false) || (s.scripts.triggers[k].once && s.done[k]) {
continue; continue;
@@ -454,33 +377,212 @@ impl Game {
s.done[k] = true; s.done[k] = true;
let name = s.scripts.triggers[k].name.clone(); let name = s.scripts.triggers[k].name.clone();
s.log(self.time, name); s.log(self.time, name);
start.push((Src::Trigger(k), ev));
} }
let (mut vars, mut rng) = (std::mem::take(&mut s.vars), s.rng); let mut threads = std::mem::take(&mut s.threads);
let r = run_fn(self, &code, &f, &mut vars, &mut rng, ev.clone()); for (src, ev) in start {
s.vars = vars; threads.push(Thread { src, frames: vec![Frame { lp: Vec::new(), idx: 0, left: -1, loop_kind: 0 }], wake: 0.0, ev });
s.rng = rng; }
match r { // run every thread whose time has come; the waiting ones stay
Ok(acts) => { let scripts = std::mem::take(&mut s.scripts);
for a in acts { let mut keep = Vec::new();
self.apply_act(&mut s, a, &ev); 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);
} }
} }
Err(e) => { if keep.len() > MAX_THREADS {
if s.error.is_none() { keep.drain(0..keep.len() - MAX_THREADS);
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.scripts = scripts;
s.threads = keep;
s.events.clear(); s.events.clear();
self.script = Some(s); 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 // what the next check compares against: living units, towers, rooms, research, zone counts
fn remember(&self, s: &mut ScriptState, first: bool) { 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(); 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)); 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] #[test]
fn zone_shapes() { fn zone_shapes() {
let r = rect("r", (2.0, 2.0), (4.0, 3.0)); let r = rect("r", (2.0, 2.0), (4.0, 3.0));