Add campaign editor script runtime and debug overlays
This commit is contained in:
1 parent
cd35ca8e3d
commit
b06c48172b
13 files changed
+2211
-66
No files matched your search
Generated
+319
-2
@@ -8,6 +8,19 @@ 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"
|
||||||
@@ -26,6 +39,18 @@ 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"
|
||||||
@@ -38,6 +63,15 @@ 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"
|
||||||
@@ -50,6 +84,39 @@ 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"
|
||||||
@@ -68,11 +135,18 @@ 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",
|
||||||
]
|
]
|
||||||
@@ -119,6 +193,41 @@ 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"
|
||||||
@@ -155,6 +264,17 @@ 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"
|
||||||
@@ -258,19 +378,40 @@ 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",
|
"bitflags 1.3.2",
|
||||||
"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"
|
||||||
@@ -295,6 +436,41 @@ 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"
|
||||||
@@ -322,7 +498,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -344,6 +520,46 @@ 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"
|
||||||
@@ -355,6 +571,21 @@ 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"
|
||||||
@@ -370,6 +601,72 @@ 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"
|
||||||
@@ -392,6 +689,26 @@ 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"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license = "MIT"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
macroquad = { version = "0.4" }
|
macroquad = { version = "0.4" }
|
||||||
|
rhai = { version = "1.27.0", default-features = false, features = ["std"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
+42
-1
@@ -219,6 +219,7 @@ set.tab.sound = Sprache & Ton
|
|||||||
set.tab.camera = Kamera
|
set.tab.camera = Kamera
|
||||||
set.tab.graphics = Grafik
|
set.tab.graphics = Grafik
|
||||||
set.tab.game = Spiel
|
set.tab.game = Spiel
|
||||||
|
set.tab.debug = Debug
|
||||||
set.tab.keys = Tasten
|
set.tab.keys = Tasten
|
||||||
set.language = Sprache
|
set.language = Sprache
|
||||||
set.volume = Effekt-Lautstärke
|
set.volume = Effekt-Lautstärke
|
||||||
@@ -339,7 +340,12 @@ dir.nw = Nordwesten
|
|||||||
|
|
||||||
val.auto = Auto ({0}x)
|
val.auto = Auto ({0}x)
|
||||||
set.render_scale = 3D-Auflösung
|
set.render_scale = 3D-Auflösung
|
||||||
set.debug_fps = FPS-Anzeige
|
set.debug_fps = FPS anzeigen
|
||||||
|
set.debug_frame_time = Framezeit anzeigen
|
||||||
|
set.debug_ping = Ping anzeigen
|
||||||
|
set.debug_trigger_zones = Triggerzonen anzeigen
|
||||||
|
set.debug_command_markers = Debug-Befehlsmarker anzeigen
|
||||||
|
set.debug_path_lines = Pfadlinien anzeigen
|
||||||
|
|
||||||
hint.pickup_prisoner_where = Gefangene können nur auf eigenem Gebiet aufgehoben werden.
|
hint.pickup_prisoner_where = Gefangene können nur auf eigenem Gebiet aufgehoben werden.
|
||||||
hint.punch_where = Die Hand kann Helden nur auf eigenem Gebiet schlagen.
|
hint.punch_where = Die Hand kann Helden nur auf eigenem Gebiet schlagen.
|
||||||
@@ -598,6 +604,36 @@ account.users_changed = Benutzer aktualisiert.
|
|||||||
standalone.running = Die Standalone-Version wird vorbereitet ...
|
standalone.running = Die Standalone-Version wird vorbereitet ...
|
||||||
standalone.done = DungeonCraft.html heruntergeladen: In jedem Browser öffnen, kein Server nötig. Deine heruntergeladenen Spielstände lassen sich dort auch laden.
|
standalone.done = DungeonCraft.html heruntergeladen: In jedem Browser öffnen, kein Server nötig. Deine heruntergeladenen Spielstände lassen sich dort auch laden.
|
||||||
standalone.failed = Die Standalone-Version konnte nicht heruntergeladen werden.
|
standalone.failed = Die Standalone-Version konnte nicht heruntergeladen werden.
|
||||||
|
menu.campaign_editor = Kampagneneditor
|
||||||
|
campaign.title = Kampagneneditor
|
||||||
|
campaign.new = Neu
|
||||||
|
campaign.open = Öffnen
|
||||||
|
campaign.save = Speichern
|
||||||
|
campaign.add = Add
|
||||||
|
campaign.delete = Entf
|
||||||
|
campaign.start = Start
|
||||||
|
campaign.choose = Kampagnendatei auswählen.
|
||||||
|
campaign.loaded = Kampagne geladen: {0}
|
||||||
|
campaign.saved = Kampagne gespeichert: {0}
|
||||||
|
campaign.invalid = Fehler in der Kampagnendatei: {0}
|
||||||
|
campaign.empty = Keine Missionen.
|
||||||
|
campaign.missions = Missionen: {0}
|
||||||
|
campaign.name = Kampagne: {0}
|
||||||
|
campaign.author = Autor: {0}
|
||||||
|
campaign.mission_no = {0}. {1}
|
||||||
|
campaign.next = Nächste: {0}
|
||||||
|
campaign.branches = Verzweigungen: {0}
|
||||||
|
campaign.goals = Ziele: {0}
|
||||||
|
campaign.zones = Zonen: {0}
|
||||||
|
campaign.script = Skript: {0}
|
||||||
|
campaign.prompt_name = Kampagnenname
|
||||||
|
campaign.prompt_title = Missionstitel
|
||||||
|
campaign.prompt_goals = Ziele, mit Komma getrennt
|
||||||
|
campaign.prompt_next = Nächster Missionsname
|
||||||
|
campaign.prompt_branches = Verzweigungen, mit Semikolon getrennt: id:goal:next:prio:title
|
||||||
|
campaign.prompt_script = Rhai-Skript, z.B. fn on_start(m) { m.log("..."); m.objective("..."); m.spawn_at("HEROES","Knight",20,20,3,2); }
|
||||||
|
campaign.updated = Geändert.
|
||||||
|
campaign.error_no_mission = Keine Mission ausgewählt.
|
||||||
job.register = Meldet sich am Portal
|
job.register = Meldet sich am Portal
|
||||||
job.make_bed = Richtet sein Bett ein
|
job.make_bed = Richtet sein Bett ein
|
||||||
job.fetch_wage = Holt sich seinen Lohn
|
job.fetch_wage = Holt sich seinen Lohn
|
||||||
@@ -842,6 +878,11 @@ research.done = Hier gibt es nichts mehr zu erforschen.
|
|||||||
msg.researched = Erforscht: {0}! Jetzt im Baumenü.
|
msg.researched = Erforscht: {0}! Jetzt im Baumenü.
|
||||||
msg.tool_research = Noch nicht erforscht - Kreaturen in der Bibliothek arbeiten daran.
|
msg.tool_research = Noch nicht erforscht - Kreaturen in der Bibliothek arbeiten daran.
|
||||||
|
|
||||||
|
msg.script = {0}
|
||||||
|
msg.script_objective = Ziel: {0}
|
||||||
|
msg.script_error = Skriptfehler: {0}
|
||||||
|
msg.script_language = Unbekannte Missionssprache: {0}
|
||||||
|
|
||||||
# spells
|
# spells
|
||||||
hint.cavein_where = Ein Einsturz braucht freien Boden (keine Räume) unter dem Zeiger.
|
hint.cavein_where = Ein Einsturz braucht freien Boden (keine Räume) unter dem Zeiger.
|
||||||
hint.spell_own = Wirke ihn auf deine eigenen Kreaturen.
|
hint.spell_own = Wirke ihn auf deine eigenen Kreaturen.
|
||||||
|
|||||||
+42
-1
@@ -220,6 +220,7 @@ set.tab.sound = Language & Sound
|
|||||||
set.tab.camera = Camera
|
set.tab.camera = Camera
|
||||||
set.tab.graphics = Graphics
|
set.tab.graphics = Graphics
|
||||||
set.tab.game = Game
|
set.tab.game = Game
|
||||||
|
set.tab.debug = Debug
|
||||||
set.tab.keys = Keys
|
set.tab.keys = Keys
|
||||||
set.language = Language
|
set.language = Language
|
||||||
set.volume = Sound volume
|
set.volume = Sound volume
|
||||||
@@ -340,7 +341,12 @@ dir.nw = north-west
|
|||||||
|
|
||||||
val.auto = Auto ({0}x)
|
val.auto = Auto ({0}x)
|
||||||
set.render_scale = 3D resolution
|
set.render_scale = 3D resolution
|
||||||
set.debug_fps = FPS debug
|
set.debug_fps = Show FPS
|
||||||
|
set.debug_frame_time = Show frame time
|
||||||
|
set.debug_ping = Show ping
|
||||||
|
set.debug_trigger_zones = Show trigger zones
|
||||||
|
set.debug_command_markers = Show debug command markers
|
||||||
|
set.debug_path_lines = Show path lines
|
||||||
|
|
||||||
hint.pickup_prisoner_where = Prisoners can only be picked up on your own territory.
|
hint.pickup_prisoner_where = Prisoners can only be picked up on your own territory.
|
||||||
hint.punch_where = The hand can only punch heroes on your own territory.
|
hint.punch_where = The hand can only punch heroes on your own territory.
|
||||||
@@ -599,6 +605,36 @@ account.users_changed = Users updated.
|
|||||||
standalone.running = Preparing the standalone version ...
|
standalone.running = Preparing the standalone version ...
|
||||||
standalone.done = DungeonCraft.html downloaded: open it in any browser, no server needed. Your downloaded savegames load there too.
|
standalone.done = DungeonCraft.html downloaded: open it in any browser, no server needed. Your downloaded savegames load there too.
|
||||||
standalone.failed = The standalone version could not be downloaded.
|
standalone.failed = The standalone version could not be downloaded.
|
||||||
|
menu.campaign_editor = Campaign Editor
|
||||||
|
campaign.title = Campaign Editor
|
||||||
|
campaign.new = New
|
||||||
|
campaign.open = Open
|
||||||
|
campaign.save = Save
|
||||||
|
campaign.add = Add
|
||||||
|
campaign.delete = Del
|
||||||
|
campaign.start = Start
|
||||||
|
campaign.choose = Choose a campaign file.
|
||||||
|
campaign.loaded = Campaign loaded: {0}
|
||||||
|
campaign.saved = Campaign saved: {0}
|
||||||
|
campaign.invalid = Campaign file error: {0}
|
||||||
|
campaign.empty = No missions.
|
||||||
|
campaign.missions = Missions: {0}
|
||||||
|
campaign.name = Campaign: {0}
|
||||||
|
campaign.author = Author: {0}
|
||||||
|
campaign.mission_no = {0}. {1}
|
||||||
|
campaign.next = Next: {0}
|
||||||
|
campaign.branches = Branches: {0}
|
||||||
|
campaign.goals = Goals: {0}
|
||||||
|
campaign.zones = Zones: {0}
|
||||||
|
campaign.script = Script: {0}
|
||||||
|
campaign.prompt_name = Campaign name
|
||||||
|
campaign.prompt_title = Mission title
|
||||||
|
campaign.prompt_goals = Goals, comma separated
|
||||||
|
campaign.prompt_next = Next mission key
|
||||||
|
campaign.prompt_branches = Branches, semicolon separated: id:goal:next:prio:title
|
||||||
|
campaign.prompt_script = Rhai script, e.g. fn on_start(m) { m.log("..."); m.objective("..."); m.spawn_at("HEROES","Knight",20,20,3,2); }
|
||||||
|
campaign.updated = Updated.
|
||||||
|
campaign.error_no_mission = No mission selected.
|
||||||
job.register = Reporting at the portal
|
job.register = Reporting at the portal
|
||||||
job.make_bed = Setting up its bed
|
job.make_bed = Setting up its bed
|
||||||
job.fetch_wage = Fetching its wage
|
job.fetch_wage = Fetching its wage
|
||||||
@@ -843,6 +879,11 @@ research.done = Nothing left to research here.
|
|||||||
msg.researched = Researched: {0}! It is in the build menu now.
|
msg.researched = Researched: {0}! It is in the build menu now.
|
||||||
msg.tool_research = Not researched yet - creatures studying in the library work on it.
|
msg.tool_research = Not researched yet - creatures studying in the library work on it.
|
||||||
|
|
||||||
|
msg.script = {0}
|
||||||
|
msg.script_objective = Objective: {0}
|
||||||
|
msg.script_error = Script error: {0}
|
||||||
|
msg.script_language = Unsupported mission script language: {0}
|
||||||
|
|
||||||
# spells
|
# spells
|
||||||
hint.cavein_where = A cave-in needs open floor (not rooms) under the pointer.
|
hint.cavein_where = A cave-in needs open floor (not rooms) under the pointer.
|
||||||
hint.spell_own = Cast it on your own creatures.
|
hint.spell_own = Cast it on your own creatures.
|
||||||
|
|||||||
+49
-3
@@ -29,7 +29,7 @@ pub struct FortDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct MissionDef {
|
pub struct MissionDef {
|
||||||
pub key: &'static str,
|
pub key: String,
|
||||||
pub size: (usize, usize),
|
pub size: (usize, usize),
|
||||||
pub seed: u32,
|
pub seed: u32,
|
||||||
pub gold: f32,
|
pub gold: f32,
|
||||||
@@ -145,9 +145,9 @@ impl FortDef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn base(key: &'static str, size: (usize, usize), seed: u32, goals: Vec<Goal>) -> MissionDef {
|
fn base(key: &str, size: (usize, usize), seed: u32, goals: Vec<Goal>) -> MissionDef {
|
||||||
MissionDef {
|
MissionDef {
|
||||||
key,
|
key: key.to_string(),
|
||||||
size,
|
size,
|
||||||
seed,
|
seed,
|
||||||
gold: 3000.0,
|
gold: 3000.0,
|
||||||
@@ -169,6 +169,21 @@ fn base(key: &'static str, size: (usize, usize), seed: u32, goals: Vec<Goal>) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Converts one campaign editor mission into the same shape the campaign generator uses.
|
||||||
|
pub fn mission_def_from_doc(d: &crate::campaign_doc::MissionDoc) -> MissionDef {
|
||||||
|
let mut out = base(&d.key, d.size, d.seed, crate::campaign_doc::parse_goals(&d.goals));
|
||||||
|
out.gold = d.gold;
|
||||||
|
out.portals = d.portals;
|
||||||
|
out.treasures = d.treasures;
|
||||||
|
out.waves = d.waves;
|
||||||
|
out.wave_limit = d.wave_limit;
|
||||||
|
out.first_wave = d.first_wave;
|
||||||
|
out.gate = d.waves;
|
||||||
|
out.gate_defenders = d.waves;
|
||||||
|
out.time_limit = d.time_limit;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// Rivers are at least four tiles wide: a ranged attack just reaches across.
|
// Rivers are at least four tiles wide: a ranged attack just reaches across.
|
||||||
pub fn missions() -> Vec<MissionDef> {
|
pub fn missions() -> Vec<MissionDef> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
@@ -487,6 +502,16 @@ pub fn start_coop_mission(i: usize, carry: Option<&Carry>) -> Game {
|
|||||||
fn start_mission_mode(i: usize, carry: Option<&Carry>, coop: bool) -> Game {
|
fn start_mission_mode(i: usize, carry: Option<&Carry>, coop: bool) -> Game {
|
||||||
let all = missions();
|
let all = missions();
|
||||||
let d = &all[i.min(all.len() - 1)];
|
let d = &all[i.min(all.len() - 1)];
|
||||||
|
start_mission_def(d, i, carry, coop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mission generated from the campaign editor file: same start pipeline, but without the
|
||||||
|
// built-in campaign table and without language-file story keys for the title.
|
||||||
|
pub fn start_mission_doc(d: &MissionDef, i: usize, carry: Option<&Carry>) -> Game {
|
||||||
|
start_mission_def(d, i, carry, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool) -> Game {
|
||||||
let (w, h) = d.size;
|
let (w, h) = d.size;
|
||||||
let opts = GenOpts { portals: Some(d.portals), treasures: Some(d.treasures), gate: d.gate, core_y: None };
|
let opts = GenOpts { portals: Some(d.portals), treasures: Some(d.treasures), gate: d.gate, core_y: None };
|
||||||
let map = Map::generate_with(d.seed, w, h, &opts);
|
let map = Map::generate_with(d.seed, w, h, &opts);
|
||||||
@@ -600,6 +625,27 @@ pub fn carry_from(g: &Game) -> Carry {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_custom_editor_mission_can_be_started() {
|
||||||
|
let doc = crate::campaign_doc::MissionDoc {
|
||||||
|
key: "custom01".into(),
|
||||||
|
title: "Custom".into(),
|
||||||
|
size: (72, 72),
|
||||||
|
seed: 9991,
|
||||||
|
gold: 3500.0,
|
||||||
|
goals: vec!["KillAll".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let def = mission_def_from_doc(&doc);
|
||||||
|
assert_eq!(def.key, "custom01");
|
||||||
|
assert_eq!(def.goals, vec![Goal::KillAll]);
|
||||||
|
|
||||||
|
let g = start_mission_doc(&def, 0, None);
|
||||||
|
let m = g.mission.as_ref().expect("the custom mission has mission state");
|
||||||
|
assert_eq!(m.goals, vec![Goal::KillAll]);
|
||||||
|
assert_eq!(g.gold, 3500.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_missions_build() {
|
fn all_missions_build() {
|
||||||
for (i, d) in missions().iter().enumerate() {
|
for (i, d) in missions().iter().enumerate() {
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
// Data model for shareable campaign files (.dccamp).
|
||||||
|
// This is the first editor layer: JSON missions, branch goals, scripts and trigger zones.
|
||||||
|
// The gameplay-side campaign start for custom missions comes in a later step.
|
||||||
|
|
||||||
|
use crate::game::Goal;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub const CAMPAIGN_FORMAT: &str = "dungeoncraft.campaign";
|
||||||
|
pub const CAMPAIGN_VERSION: u32 = 1;
|
||||||
|
pub const CAMPAIGN_EXTENSION: &str = "dccamp";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CampaignProgress {
|
||||||
|
pub done: Vec<bool>,
|
||||||
|
pub completed_choice: Vec<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CampaignProgress {
|
||||||
|
pub fn for_doc(doc: &CampaignDoc) -> CampaignProgress {
|
||||||
|
let n = doc.missions.len();
|
||||||
|
CampaignProgress {
|
||||||
|
done: vec![false; n],
|
||||||
|
completed_choice: vec![None; n],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure(&mut self, n: usize) {
|
||||||
|
self.done.resize(n, false);
|
||||||
|
self.completed_choice.resize(n, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_done(&self, i: usize) -> bool {
|
||||||
|
self.done.get(i).copied().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_done(&mut self, i: usize, n: usize, choice: Option<String>) {
|
||||||
|
self.ensure(n);
|
||||||
|
if let Some(d) = self.done.get_mut(i) {
|
||||||
|
*d = true;
|
||||||
|
}
|
||||||
|
if let Some(c) = self.completed_choice.get_mut(i) {
|
||||||
|
*c = choice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CampaignDoc {
|
||||||
|
#[serde(default = "default_format")]
|
||||||
|
pub format: String,
|
||||||
|
#[serde(default = "default_version")]
|
||||||
|
pub version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub author: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub missions: Vec<MissionDoc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MissionDoc {
|
||||||
|
#[serde(default)]
|
||||||
|
pub key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub story: String,
|
||||||
|
#[serde(default = "default_size")]
|
||||||
|
pub size: (usize, usize),
|
||||||
|
#[serde(default)]
|
||||||
|
pub seed: u32,
|
||||||
|
#[serde(default = "default_gold")]
|
||||||
|
pub gold: f32,
|
||||||
|
#[serde(default = "default_portals")]
|
||||||
|
pub portals: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub treasures: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub goals: Vec<String>,
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub waves: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wave_limit: u32,
|
||||||
|
#[serde(default = "default_first_wave")]
|
||||||
|
pub first_wave: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub time_limit: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub next: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub branches: Vec<BranchDoc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub zones: Vec<ZoneDoc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub script: Option<ScriptDoc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BranchDoc {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub goal: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub next: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub priority: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ZoneDoc {
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "default_shape")]
|
||||||
|
pub shape: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub at: Option<(f32, f32)>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub radius: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tiles: Option<Vec<(i32, i32)>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub from: Option<(f32, f32)>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub to: Option<(f32, f32)>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub width: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ScriptDoc {
|
||||||
|
#[serde(default = "default_script_language")]
|
||||||
|
pub language: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CampaignDoc {
|
||||||
|
fn default() -> Self {
|
||||||
|
CampaignDoc {
|
||||||
|
format: default_format(),
|
||||||
|
version: default_version(),
|
||||||
|
name: "Neue Kampagne".into(),
|
||||||
|
author: String::new(),
|
||||||
|
missions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MissionDoc {
|
||||||
|
fn default() -> Self {
|
||||||
|
MissionDoc {
|
||||||
|
key: String::new(),
|
||||||
|
title: String::new(),
|
||||||
|
story: String::new(),
|
||||||
|
size: default_size(),
|
||||||
|
seed: 0,
|
||||||
|
gold: default_gold(),
|
||||||
|
portals: default_portals(),
|
||||||
|
treasures: 0,
|
||||||
|
goals: Vec::new(),
|
||||||
|
waves: false,
|
||||||
|
wave_limit: 0,
|
||||||
|
first_wave: default_first_wave(),
|
||||||
|
time_limit: 0.0,
|
||||||
|
next: None,
|
||||||
|
branches: Vec::new(),
|
||||||
|
zones: Vec::new(),
|
||||||
|
script: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CampaignDoc {
|
||||||
|
pub fn load(text: &str) -> Result<CampaignDoc, String> {
|
||||||
|
serde_json::from_str::<CampaignDoc>(text).map_err(|e| format!("invalid campaign file: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> String {
|
||||||
|
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_mission(&mut self) -> &mut MissionDoc {
|
||||||
|
let n = self.missions.len() + 1;
|
||||||
|
self.missions.push(MissionDoc {
|
||||||
|
key: format!("m{n:02}"),
|
||||||
|
title: format!("Mission {n}"),
|
||||||
|
seed: (n as u32) * 101,
|
||||||
|
goals: vec!["Shards".into()],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
self.missions.last_mut().expect("the new mission is last")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_name(&self) -> String {
|
||||||
|
let base: String = self
|
||||||
|
.name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||||
|
.collect();
|
||||||
|
let base = if base.is_empty() { "campaign".to_string() } else { base };
|
||||||
|
format!("{}.{}", base, CAMPAIGN_EXTENSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Vec<String> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut keys = HashSet::new();
|
||||||
|
|
||||||
|
if self.missions.is_empty() {
|
||||||
|
errors.push("campaign has no missions".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
for mission in &self.missions {
|
||||||
|
if !valid_key(&mission.key) {
|
||||||
|
errors.push(format!("invalid mission key: {:?}", mission.key));
|
||||||
|
} else if !keys.insert(mission.key.clone()) {
|
||||||
|
errors.push(format!("duplicate mission key: {}", mission.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if mission.title.trim().is_empty() {
|
||||||
|
errors.push(format!("mission {} has no title", mission.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if mission.size.0 < 32 || mission.size.1 < 32 || mission.size.0 > 512 || mission.size.1 > 512 {
|
||||||
|
errors.push(format!("mission {} has an out-of-range size: {}x{}", mission.key, mission.size.0, mission.size.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
for raw in &mission.goals {
|
||||||
|
if parse_goal(raw).is_none() {
|
||||||
|
errors.push(format!("mission {} has an unknown goal: {}", mission.key, raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(next) = &mission.next {
|
||||||
|
if !valid_key(next) {
|
||||||
|
errors.push(format!("mission {} has an invalid next mission: {:?}", mission.key, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mission.next.as_deref().map_or(false, |next| !self.missions.iter().any(|m| m.key == next)) {
|
||||||
|
errors.push(format!("mission {} has an unknown next mission: {}", mission.key, mission.next.clone().unwrap_or_default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
for branch in &mission.branches {
|
||||||
|
if !valid_key(&branch.id) {
|
||||||
|
errors.push(format!("mission {} has an invalid branch id: {:?}", mission.key, branch.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if parse_goal(&branch.goal).is_none() {
|
||||||
|
errors.push(format!("mission {} branch {} has an unknown goal: {}", mission.key, branch.id, branch.goal));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid_key(&branch.next) {
|
||||||
|
errors.push(format!("mission {} branch {} has an invalid next mission: {:?}", mission.key, branch.id, branch.next));
|
||||||
|
} else if !self.missions.iter().any(|m| m.key == branch.next) {
|
||||||
|
errors.push(format!("mission {} branch {} points to an unknown mission: {}", mission.key, branch.id, branch.next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for mission in &self.missions {
|
||||||
|
if let Some(next) = &mission.next {
|
||||||
|
if !self.missions.iter().any(|m| m.key == *next) {
|
||||||
|
errors.push(format!("mission {} next mission does not exist: {}", mission.key, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_mission_choice<F>(&self, i: usize, goal_done: F) -> (Option<usize>, Option<String>)
|
||||||
|
where
|
||||||
|
F: Fn(Goal) -> bool,
|
||||||
|
{
|
||||||
|
let Some(mission) = self.missions.get(i) else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut matched: Vec<&BranchDoc> = mission
|
||||||
|
.branches
|
||||||
|
.iter()
|
||||||
|
.filter(|b| match parse_goal(&b.goal) {
|
||||||
|
Some(g) => goal_done(g),
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
matched.sort_by(|a, b| b.priority.unwrap_or(0).cmp(&a.priority.unwrap_or(0)));
|
||||||
|
|
||||||
|
if let Some(branch) = matched.first() {
|
||||||
|
return (self.missions.iter().position(|m| m.key == branch.next), Some(branch.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let main_done = !mission.goals.is_empty()
|
||||||
|
&& mission
|
||||||
|
.goals
|
||||||
|
.iter()
|
||||||
|
.filter_map(|g| parse_goal(g))
|
||||||
|
.all(|g| goal_done(g));
|
||||||
|
|
||||||
|
if main_done {
|
||||||
|
if let Some(next) = &mission.next {
|
||||||
|
return (self.missions.iter().position(|m| m.key == *next), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(next) = &mission.next {
|
||||||
|
return (self.missions.iter().position(|m| m.key == *next), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if mission.branches.is_empty() && i + 1 < self.missions.len() {
|
||||||
|
return (Some(i + 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_list(text: &str) -> Vec<String> {
|
||||||
|
text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_branches(text: &str) -> Vec<BranchDoc> {
|
||||||
|
text.split(';')
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| {
|
||||||
|
let parts: Vec<&str> = s.split(':').map(|p| p.trim()).collect();
|
||||||
|
BranchDoc {
|
||||||
|
id: parts.first().copied().unwrap_or_default().to_string(),
|
||||||
|
goal: parts.get(1).copied().unwrap_or_default().to_string(),
|
||||||
|
next: parts.get(2).copied().unwrap_or_default().to_string(),
|
||||||
|
priority: parts.get(3).and_then(|p| p.parse::<u32>().ok()),
|
||||||
|
title: parts.get(4).copied().unwrap_or_default().to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn branches_to_text(branches: &[BranchDoc]) -> String {
|
||||||
|
branches
|
||||||
|
.iter()
|
||||||
|
.map(|b| {
|
||||||
|
let mut line = format!("{}:{}:{}", b.id, b.goal, b.next);
|
||||||
|
if b.priority.is_some() || !b.title.is_empty() {
|
||||||
|
line.push(':');
|
||||||
|
line.push_str(&b.priority.unwrap_or(0).to_string());
|
||||||
|
}
|
||||||
|
if !b.title.is_empty() {
|
||||||
|
line.push(':');
|
||||||
|
line.push_str(&b.title);
|
||||||
|
}
|
||||||
|
line
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_goals(raws: &[String]) -> Vec<Goal> {
|
||||||
|
raws.iter().filter_map(|r| parse_goal(r)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_goal(raw: &str) -> Option<Goal> {
|
||||||
|
let s = raw.trim();
|
||||||
|
match s {
|
||||||
|
"Shards" => return Some(Goal::Shards),
|
||||||
|
"Heart" => return Some(Goal::Heart),
|
||||||
|
"KillAll" => return Some(Goal::KillAll),
|
||||||
|
"Towers" => return Some(Goal::Towers),
|
||||||
|
"Rivals" => return Some(Goal::Rivals),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n) = number_after_prefix(s, "Loot") {
|
||||||
|
return Some(Goal::Loot(n));
|
||||||
|
}
|
||||||
|
if let Some(n) = number_after_prefix(s, "Survive") {
|
||||||
|
return Some(Goal::Survive(n));
|
||||||
|
}
|
||||||
|
if let Some(n) = number_after_prefix(s, "Springs") {
|
||||||
|
return Some(Goal::Springs(n));
|
||||||
|
}
|
||||||
|
if let Some(n) = number_after_prefix(s, "Freed") {
|
||||||
|
return Some(Goal::Freed(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number_after_prefix(s: &str, prefix: &str) -> Option<u32> {
|
||||||
|
let rest = s.strip_prefix(prefix)?;
|
||||||
|
if !rest.starts_with('(') || !rest.ends_with(')') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
rest[1..rest.len() - 1].trim().parse::<u32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_key(s: &str) -> bool {
|
||||||
|
!s.is_empty() && s.len() <= 48 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_format() -> String {
|
||||||
|
CAMPAIGN_FORMAT.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_version() -> u32 {
|
||||||
|
CAMPAIGN_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_size() -> (usize, usize) {
|
||||||
|
(96, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_gold() -> f32 {
|
||||||
|
3000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_portals() -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_first_wave() -> f32 {
|
||||||
|
300.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_false() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_shape() -> String {
|
||||||
|
"circle".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_script_language() -> String {
|
||||||
|
"rhai".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_campaign_file_reads_missions_branches_and_scripts() {
|
||||||
|
let text = r#"{
|
||||||
|
"format": "dungeoncraft.campaign",
|
||||||
|
"version": 1,
|
||||||
|
"name": "Meine Kampagne",
|
||||||
|
"author": "Tester",
|
||||||
|
"missions": [
|
||||||
|
{
|
||||||
|
"key": "my01",
|
||||||
|
"title": "Der erste Weg",
|
||||||
|
"size": [96, 80],
|
||||||
|
"seed": 123,
|
||||||
|
"goals": ["Shards"],
|
||||||
|
"branches": [
|
||||||
|
{ "id": "kill_path", "goal": "KillAll", "next": "my02a" },
|
||||||
|
{ "id": "loot_path", "goal": "Loot(3000)", "next": "my02b", "priority": 2 }
|
||||||
|
],
|
||||||
|
"script": { "language": "rhai", "source": "fn on_start(m) { m.log('hi'); }" }
|
||||||
|
},
|
||||||
|
{ "key": "my02a", "title": "A", "size": [96, 80] },
|
||||||
|
{ "key": "my02b", "title": "B", "size": [96, 80] }
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let campaign = CampaignDoc::load(text).expect("the campaign file parses");
|
||||||
|
assert_eq!(campaign.name, "Meine Kampagne");
|
||||||
|
assert_eq!(campaign.missions[0].branches.len(), 2);
|
||||||
|
assert_eq!(campaign.missions[0].branches[1].priority, Some(2));
|
||||||
|
assert_eq!(parse_goal(campaign.missions[0].branches[1].goal.as_str()), Some(Goal::Loot(3000)));
|
||||||
|
assert!(campaign.validate().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_goals_choose_the_next_mission() {
|
||||||
|
let campaign = CampaignDoc {
|
||||||
|
name: "Branches".into(),
|
||||||
|
missions: vec![
|
||||||
|
MissionDoc {
|
||||||
|
key: "m01".into(),
|
||||||
|
title: "Start".into(),
|
||||||
|
branches: vec![BranchDoc { id: "kill".into(), goal: "KillAll".into(), next: "m02a".into(), priority: Some(1), ..Default::default() }],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
MissionDoc { key: "m02a".into(), title: "A".into(), ..Default::default() },
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (next, choice) = campaign.next_mission_choice(0, |goal| goal == Goal::KillAll);
|
||||||
|
assert_eq!(next, Some(1));
|
||||||
|
assert_eq!(choice.as_deref(), Some("kill"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn higher_priority_branch_wins() {
|
||||||
|
let campaign = CampaignDoc {
|
||||||
|
missions: vec![
|
||||||
|
MissionDoc {
|
||||||
|
key: "m01".into(),
|
||||||
|
title: "Start".into(),
|
||||||
|
branches: vec![
|
||||||
|
BranchDoc { id: "low".into(), goal: "Shards".into(), next: "m02low".into(), priority: Some(1), ..Default::default() },
|
||||||
|
BranchDoc { id: "high".into(), goal: "Shards".into(), next: "m02high".into(), priority: Some(2), ..Default::default() },
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
MissionDoc { key: "m02low".into(), title: "Low".into(), ..Default::default() },
|
||||||
|
MissionDoc { key: "m02high".into(), title: "High".into(), ..Default::default() },
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (next, choice) = campaign.next_mission_choice(0, |goal| goal == Goal::Shards);
|
||||||
|
assert_eq!(next, Some(2));
|
||||||
|
assert_eq!(choice.as_deref(), Some("high"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_campaign_starts_with_one_playable_mission() {
|
||||||
|
let mut campaign = CampaignDoc::default();
|
||||||
|
campaign.add_mission();
|
||||||
|
assert_eq!(campaign.missions.len(), 1);
|
||||||
|
assert_eq!(campaign.missions[0].key, "m01");
|
||||||
|
assert_eq!(parse_goal(campaign.missions[0].goals[0].as_str()), Some(Goal::Shards));
|
||||||
|
assert!(campaign.validate().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_text_round_trips_branch_objects() {
|
||||||
|
let branches = parse_branches("kill:KillAll:m02a:1:Kill path; loot:Loot(3000):m02b:2:Plunder path");
|
||||||
|
assert_eq!(branches.len(), 2);
|
||||||
|
assert_eq!(branches[0].id, "kill");
|
||||||
|
assert_eq!(branches[0].priority, Some(1));
|
||||||
|
assert_eq!(branches[0].title, "Kill path");
|
||||||
|
assert_eq!(parse_goal(branches[1].goal.as_str()), Some(Goal::Loot(3000)));
|
||||||
|
|
||||||
|
let text = branches_to_text(&branches);
|
||||||
|
assert_eq!(parse_branches(&text), branches);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_branch_next_is_reported() {
|
||||||
|
let campaign = CampaignDoc {
|
||||||
|
missions: vec![MissionDoc {
|
||||||
|
key: "my01".into(),
|
||||||
|
title: "Mission".into(),
|
||||||
|
branches: vec![BranchDoc { id: "bad".into(), goal: "Shards".into(), next: "missing".into(), ..Default::default() }],
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let errors = campaign.validate();
|
||||||
|
assert!(errors.iter().any(|e| e.contains("points to an unknown mission")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numbered_goals_parse_into_real_game_goals() {
|
||||||
|
assert_eq!(parse_goal("Survive(4)"), Some(Goal::Survive(4)));
|
||||||
|
assert_eq!(parse_goal("Springs(2)"), Some(Goal::Springs(2)));
|
||||||
|
assert_eq!(parse_goal("Freed(1)"), Some(Goal::Freed(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-4
@@ -159,7 +159,14 @@ impl Game {
|
|||||||
Act::Guard { x, y } => (point(x as f32, y as f32), MARK_GUARD, 10.0),
|
Act::Guard { x, y } => (point(x as f32, y as f32), MARK_GUARD, 10.0),
|
||||||
Act::CancelOrders => (point(self.map.core.0 as f32, self.map.core.1 as f32), MARK_CANCEL, 4.0),
|
Act::CancelOrders => (point(self.map.core.0 as f32, self.map.core.1 as f32), MARK_CANCEL, 4.0),
|
||||||
};
|
};
|
||||||
self.add_mark(x, y, kind, side, dur);
|
let fixed = matches!(act, Act::Guard { .. } | Act::Attack { .. } | Act::Flag { .. });
|
||||||
|
let dur = match act {
|
||||||
|
Act::Guard { .. } => 120.0,
|
||||||
|
Act::Attack { .. } => 20.0,
|
||||||
|
Act::Flag { .. } => 120.0,
|
||||||
|
_ => dur,
|
||||||
|
};
|
||||||
|
self.add_mark(x, y, kind, side, dur, fixed);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_act(&mut self, act: Act) {
|
fn run_act(&mut self, act: Act) {
|
||||||
@@ -226,6 +233,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_queued_command_is_applied_at_the_next_step() {
|
fn a_queued_command_is_applied_at_the_next_step() {
|
||||||
let mut g = game(7);
|
let mut g = game(7);
|
||||||
|
g.debug_markers = true;
|
||||||
let (cx, cy) = g.map.core;
|
let (cx, cy) = g.map.core;
|
||||||
let (x, y) = (cx + 6, cy); // outside the treasury ring: rock that can be marked
|
let (x, y) = (cx + 6, cy); // outside the treasury ring: rock that can be marked
|
||||||
g.queue(Act::Dig(Rect::new(x, y, x + 1, y + 1)));
|
g.queue(Act::Dig(Rect::new(x, y, x + 1, y + 1)));
|
||||||
@@ -239,14 +247,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn commands_leave_visible_markers() {
|
fn commands_leave_visible_markers() {
|
||||||
let mut g = game(12);
|
let mut g = game(12);
|
||||||
|
g.debug_markers = true;
|
||||||
let (cx, cy) = g.map.core;
|
let (cx, cy) = g.map.core;
|
||||||
g.queue(Act::Dig(Rect::new(cx + 6, cy, cx + 7, cy + 1)));
|
g.queue(Act::Dig(Rect::new(cx + 6, cy, cx + 7, cy + 1)));
|
||||||
g.queue(Act::Guard { x: cx, y: cy });
|
g.queue(Act::Guard { x: cx, y: cy });
|
||||||
g.queue(Act::Attack { ent: None, x: (cx + 20) as f32, y: (cy + 20) as f32 });
|
g.queue(Act::Attack { ent: None, x: (cx + 20) as f32, y: (cy + 20) as f32 });
|
||||||
g.update(STEP);
|
g.update(STEP);
|
||||||
assert!(g.marks.iter().any(|m| m.kind == MARK_DIG && m.side == 0), "dig marker");
|
assert!(g.marks.iter().any(|m| m.kind == MARK_DIG && m.side == 0 && !m.fixed), "dig debug marker");
|
||||||
assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.side == 0), "guard marker");
|
assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.side == 0 && m.fixed), "fixed guard flag");
|
||||||
assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.side == 0), "attack marker");
|
assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.side == 0 && m.fixed), "fixed attack flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_order_flags_do_not_need_the_debug_marker_setting() {
|
||||||
|
let mut g = game(13);
|
||||||
|
g.debug_markers = false;
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
g.queue(Act::Dig(Rect::new(cx + 6, cy, cx + 7, cy + 1)));
|
||||||
|
g.queue(Act::Guard { x: cx, y: cy });
|
||||||
|
g.queue(Act::Attack { ent: None, x: (cx + 20) as f32, y: (cy + 20) as f32 });
|
||||||
|
g.queue(Act::Flag { x: cx, y: cy });
|
||||||
|
assert!(!g.marks.iter().any(|m| m.kind == MARK_DIG), "dig stays hidden without debug markers");
|
||||||
|
assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.fixed), "guard is fixed");
|
||||||
|
assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.fixed), "attack is fixed");
|
||||||
|
assert!(g.marks.iter().any(|m| m.kind == MARK_FLAG && m.fixed), "gather flag is fixed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+9
-4
@@ -616,6 +616,8 @@ pub struct Game {
|
|||||||
pub marks: Vec<Mark>, // short-lived markers for commands given by the masters
|
pub marks: Vec<Mark>, // short-lived markers for commands given by the masters
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub hints: bool, // show hint messages (setting)
|
pub hints: bool, // show hint messages (setting)
|
||||||
|
#[serde(skip)]
|
||||||
|
pub debug_markers: bool, // show debug-only command markers
|
||||||
#[serde(skip, default = "hint_times")]
|
#[serde(skip, default = "hint_times")]
|
||||||
hint_t: [f32; 32], // last time each hint slot was shown
|
hint_t: [f32; 32], // last time each hint slot was shown
|
||||||
}
|
}
|
||||||
@@ -646,6 +648,8 @@ pub struct Mark {
|
|||||||
pub kind: u8,
|
pub kind: u8,
|
||||||
pub side: u8,
|
pub side: u8,
|
||||||
pub until: u64,
|
pub until: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fixed: bool, // guard/attack/gather order flags are shown without the debug setting
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hint_times() -> [f32; 32] {
|
fn hint_times() -> [f32; 32] {
|
||||||
@@ -815,6 +819,7 @@ impl Game {
|
|||||||
entry_history: Vec::new(),
|
entry_history: Vec::new(),
|
||||||
planned_entries: Vec::new(),
|
planned_entries: Vec::new(),
|
||||||
hints: true,
|
hints: true,
|
||||||
|
debug_markers: false,
|
||||||
me: Owner::Player,
|
me: Owner::Player,
|
||||||
me_side: 0,
|
me_side: 0,
|
||||||
local_side: 0,
|
local_side: 0,
|
||||||
@@ -3841,19 +3846,19 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_mark(&mut self, x: f32, y: f32, kind: u8, side: u8, duration: f32) {
|
pub fn add_mark(&mut self, x: f32, y: f32, kind: u8, side: u8, duration: f32, fixed: bool) {
|
||||||
if duration <= 0.0 || !x.is_finite() || !y.is_finite() {
|
if (!fixed && !self.debug_markers) || duration <= 0.0 || !x.is_finite() || !y.is_finite() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let until = self.sim_steps + (duration / STEP).ceil() as u64;
|
let until = self.sim_steps + (duration / STEP).ceil() as u64;
|
||||||
if let Some(m) = self.marks.iter_mut().find(|m| (m.x - x).abs() < 0.75 && (m.y - y).abs() < 0.75 && m.kind == kind && m.side == side) {
|
if let Some(m) = self.marks.iter_mut().find(|m| (m.x - x).abs() < 0.75 && (m.y - y).abs() < 0.75 && m.kind == kind && m.side == side && m.fixed == fixed) {
|
||||||
m.until = m.until.max(until);
|
m.until = m.until.max(until);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.marks.len() >= 80 {
|
if self.marks.len() >= 80 {
|
||||||
self.marks.remove(0);
|
self.marks.remove(0);
|
||||||
}
|
}
|
||||||
self.marks.push(Mark { x, y, kind, side, until });
|
self.marks.push(Mark { x, y, kind, side, until, fixed });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn creature_choose(&mut self, e: &mut Ent) {
|
fn creature_choose(&mut self, e: &mut Ent) {
|
||||||
|
|||||||
+649
-41
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod campaign;
|
mod campaign;
|
||||||
|
mod campaign_doc;
|
||||||
mod cloud;
|
mod cloud;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod demo;
|
mod demo;
|
||||||
@@ -25,6 +26,7 @@ mod rival;
|
|||||||
mod render3d;
|
mod render3d;
|
||||||
mod rooms;
|
mod rooms;
|
||||||
mod save;
|
mod save;
|
||||||
|
mod script;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod sfx;
|
mod sfx;
|
||||||
mod tracks;
|
mod tracks;
|
||||||
@@ -45,13 +47,14 @@ use macroquad::prelude::*;
|
|||||||
use map::{inb, mh, mw};
|
use map::{inb, mh, mw};
|
||||||
use menu::{Menu, MenuAction, Page};
|
use menu::{Menu, MenuAction, Page};
|
||||||
use lobby::{MatchClient, Phase, StartInfo};
|
use lobby::{MatchClient, Phase, StartInfo};
|
||||||
use render3d::{Cam, Overlay, R3d};
|
use render3d::{Cam, Overlay, R3d, disc, line3};
|
||||||
use settings::{bindable_keys, Action, Settings};
|
use settings::{bindable_keys, Action, Settings};
|
||||||
use sound::SoundBank;
|
use sound::SoundBank;
|
||||||
use touch::{Gesture, TouchInput};
|
use touch::{Gesture, TouchInput};
|
||||||
use ui::{hover_info, Tool, Ui, UiAction, TOOLS};
|
use ui::{chat_color, hover_info, Tool, Ui, UiAction, TOOLS};
|
||||||
|
|
||||||
const SETTINGS_KEY: &str = "dc.settings";
|
const SETTINGS_KEY: &str = "dc.settings";
|
||||||
|
const FRAME_HIST: usize = 64;
|
||||||
const AUTOSAVE_EVERY: f32 = 120.0; // seconds of play between autosaves
|
const AUTOSAVE_EVERY: f32 = 120.0; // seconds of play between autosaves
|
||||||
const AUTOSAVE_CACHE_EVERY: f32 = 15.0; // snapshot kept in JS, written when the tab is left
|
const AUTOSAVE_CACHE_EVERY: f32 = 15.0; // snapshot kept in JS, written when the tab is left
|
||||||
|
|
||||||
@@ -175,6 +178,38 @@ fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) {
|
|||||||
(g, cam)
|
(g, cam)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prompt_line(title: &str, initial: &str, max: usize) -> Option<String> {
|
||||||
|
let value = platform::prompt(title, initial)?;
|
||||||
|
let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect();
|
||||||
|
let clean = clean.trim().to_string();
|
||||||
|
if clean.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_optional_line(title: &str, initial: &str, max: usize) -> Option<String> {
|
||||||
|
let value = platform::prompt(title, initial)?;
|
||||||
|
let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect();
|
||||||
|
Some(clean.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_campaign_key(value: &str) -> String {
|
||||||
|
value.trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').take(48).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn custom_mission_game(def: &campaign::MissionDef, i: usize) -> (Game, Cam) {
|
||||||
|
let g = campaign::start_mission_doc(def, i, None);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||||||
|
(g, cam)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn custom_next_choice(campaign: &campaign_doc::CampaignDoc, i: usize, g: &Game) -> (Option<usize>, Option<String>) {
|
||||||
|
campaign.next_mission_choice(i, |goal| g.goal_done(goal))
|
||||||
|
}
|
||||||
|
|
||||||
const PROGRESS_KEY: &str = "dc.campaign30"; // new key: the 30-mission order differs from the old one
|
const PROGRESS_KEY: &str = "dc.campaign30"; // new key: the 30-mission order differs from the old one
|
||||||
|
|
||||||
fn store_progress(p: &campaign::Progress) {
|
fn store_progress(p: &campaign::Progress) {
|
||||||
@@ -372,6 +407,197 @@ fn store_settings(s: &Settings) {
|
|||||||
platform::storage_set(SETTINGS_KEY, &s.to_json());
|
platform::storage_set(SETTINGS_KEY, &s.to_json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_game_settings(g: &mut Game, s: &Settings) {
|
||||||
|
g.hints = s.hints;
|
||||||
|
g.debug_markers = s.debug_command_markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_custom_script(g: &mut Game, doc: &campaign_doc::MissionDoc) {
|
||||||
|
let Some(script) = doc.script.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if script.source.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !script.language.is_empty() && !script.language.eq_ignore_ascii_case("rhai") {
|
||||||
|
g.msg("msg.script_language", vec![script.language.clone()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match script::run_start(&script.source) {
|
||||||
|
Ok(m) => {
|
||||||
|
for message in m.messages() {
|
||||||
|
g.msg("msg.script", vec![message]);
|
||||||
|
}
|
||||||
|
if let Some(objective) = m.objective() {
|
||||||
|
g.msg("msg.script_objective", vec![objective]);
|
||||||
|
}
|
||||||
|
for action in m.actions() {
|
||||||
|
apply_script_action(g, doc, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
g.msg("msg.script_error", vec![error]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_script_source(doc: &campaign_doc::MissionDoc, source: &str) -> Result<(), String> {
|
||||||
|
match script::run_start(source) {
|
||||||
|
Ok(m) => {
|
||||||
|
for action in m.actions() {
|
||||||
|
match action {
|
||||||
|
script::ScriptAction::Spawn { kind, .. } => {
|
||||||
|
if script::parse_spawn_kind(&kind).is_none() {
|
||||||
|
return Err(format!("unknown creature: {kind}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
script::ScriptAction::SpawnInZone { zone, kind, .. } => {
|
||||||
|
if script::parse_spawn_kind(&kind).is_none() {
|
||||||
|
return Err(format!("unknown creature: {kind}"));
|
||||||
|
}
|
||||||
|
if !doc.zones.iter().any(|z| z.name == zone) {
|
||||||
|
return Err(format!("unknown zone: {zone}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
script::ScriptAction::Wave { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_script_action(g: &mut Game, doc: &campaign_doc::MissionDoc, action: script::ScriptAction) {
|
||||||
|
match action {
|
||||||
|
script::ScriptAction::Spawn { side, kind, x, y, count, level } => {
|
||||||
|
let Some(kind) = script::parse_spawn_kind(&kind) else {
|
||||||
|
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
spawn_script_units(g, side, kind, &vec![(x, y); count.max(0) as usize], level);
|
||||||
|
}
|
||||||
|
script::ScriptAction::SpawnInZone { zone, kind, side, count, level } => {
|
||||||
|
let Some(kind) = script::parse_spawn_kind(&kind) else {
|
||||||
|
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(zone_doc) = doc.zones.iter().find(|z| z.name == zone) else {
|
||||||
|
g.msg("msg.script_error", vec![format!("unknown zone: {zone}")]);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let positions = zone_spawn_positions(g, zone_doc, count);
|
||||||
|
if positions.is_empty() {
|
||||||
|
g.msg("msg.script_error", vec![format!("no usable spawn position in zone: {zone}")]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_script_units(g, side, kind, &positions, level);
|
||||||
|
}
|
||||||
|
script::ScriptAction::Wave { count } => {
|
||||||
|
apply_script_wave(g, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_script_wave(g: &mut Game, count: i32) {
|
||||||
|
let count = count.max(0) as u32;
|
||||||
|
if count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(m) = g.mission.as_mut() {
|
||||||
|
m.waves = true;
|
||||||
|
if m.wave_limit != 0 {
|
||||||
|
m.wave_limit = m.wave_limit.max(g.wave.saturating_add(count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.wave_timer = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_script_units(g: &mut Game, side: i32, kind: game::EK, positions: &[(f32, f32)], level: i32) {
|
||||||
|
let positions: Vec<(f32, f32)> = positions.iter().copied().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).collect();
|
||||||
|
if positions.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let old_side = g.me_side;
|
||||||
|
match side {
|
||||||
|
0 => g.me_side = 0,
|
||||||
|
1 => g.me_side = 1,
|
||||||
|
2 | 3 | 4 => g.me_side = side as u8,
|
||||||
|
_ => {
|
||||||
|
g.msg("msg.script_error", vec![format!("unknown script side: {side}")]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (x, y) in positions {
|
||||||
|
g.spawn(kind, side == 1, x, y, level.max(1) as u32);
|
||||||
|
}
|
||||||
|
g.me_side = old_side;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_script_spawn_pos(_g: &Game, x: f32, y: f32) -> bool {
|
||||||
|
x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0 && x < mw() as f32 && y < mh() as f32 && inb(x as i32, y as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zone_spawn_positions(g: &Game, zone: &campaign_doc::ZoneDoc, count: i32) -> Vec<(f32, f32)> {
|
||||||
|
let count = count.clamp(1, 10) as usize;
|
||||||
|
let core = g.map.core;
|
||||||
|
let center = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
|
||||||
|
let mut out = Vec::new();
|
||||||
|
match zone.shape.as_str() {
|
||||||
|
"rect" | "rectangle" | "square" => {
|
||||||
|
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||||||
|
let x0 = from.0.min(to.0);
|
||||||
|
let x1 = from.0.max(to.0);
|
||||||
|
let y0 = from.1.min(to.1);
|
||||||
|
let y1 = from.1.max(to.1);
|
||||||
|
let cols = ((count as f32).sqrt().ceil()) as usize;
|
||||||
|
for k in 0..count {
|
||||||
|
let r = k / cols;
|
||||||
|
let c = k % cols;
|
||||||
|
let tx = if cols > 1 { c as f32 / (cols as f32 - 1.0) } else { 0.5 };
|
||||||
|
let rows = ((count as f32) / cols as f32).ceil() as usize;
|
||||||
|
let ty = if rows > 1 { r as f32 / (rows as f32 - 1.0) } else { 0.5 };
|
||||||
|
out.push((x0 + 0.5 + tx * (x1 - x0), y0 + 0.5 + ty * (y1 - y0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"line" | "path" => {
|
||||||
|
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||||||
|
let x0 = from.0 + 0.5;
|
||||||
|
let y0 = from.1 + 0.5;
|
||||||
|
let x1 = to.0 + 0.5;
|
||||||
|
let y1 = to.1 + 0.5;
|
||||||
|
for k in 0..count {
|
||||||
|
let t = (k as f32 + 0.5) / count as f32;
|
||||||
|
out.push((x0 + t * (x1 - x0), y0 + t * (y1 - y0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tiles" => {
|
||||||
|
if let Some(tiles) = zone.tiles.as_ref() {
|
||||||
|
for (x, y) in tiles.iter().take(count) {
|
||||||
|
out.push((*x as f32 + 0.5, *y as f32 + 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let radius = zone.radius.unwrap_or(2.0).max(0.0);
|
||||||
|
let cx = center.0 + 0.5;
|
||||||
|
let cy = center.1 + 0.5;
|
||||||
|
if count == 1 || radius <= 0.5 {
|
||||||
|
out.push((cx, cy));
|
||||||
|
} else {
|
||||||
|
let r = radius.min(radius.max(0.5) * 0.75);
|
||||||
|
for k in 0..count {
|
||||||
|
let angle = (k as f32) * std::f32::consts::TAU / count as f32;
|
||||||
|
out.push((cx + angle.cos() * r, cy + angle.sin() * r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.into_iter().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).take(count).collect()
|
||||||
|
}
|
||||||
|
|
||||||
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
|
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
|
||||||
fn save_file_name() -> String {
|
fn save_file_name() -> String {
|
||||||
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
|
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
|
||||||
@@ -396,6 +622,13 @@ struct App {
|
|||||||
coop: bool,
|
coop: bool,
|
||||||
fps: f32,
|
fps: f32,
|
||||||
renderer: String,
|
renderer: String,
|
||||||
|
frame_ms: f32,
|
||||||
|
frame_hist: [f32; FRAME_HIST],
|
||||||
|
frame_hist_pos: usize,
|
||||||
|
custom_campaign: Option<campaign_doc::CampaignDoc>,
|
||||||
|
custom_progress: Option<campaign_doc::CampaignProgress>,
|
||||||
|
custom_mission_index: Option<usize>,
|
||||||
|
custom_next_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -417,7 +650,7 @@ impl App {
|
|||||||
apply_settings(&self.settings, r3d);
|
apply_settings(&self.settings, r3d);
|
||||||
store_settings(&self.settings);
|
store_settings(&self.settings);
|
||||||
}
|
}
|
||||||
self.game.hints = self.settings.hints;
|
apply_game_settings(&mut self.game, &self.settings);
|
||||||
self.game.coop = false;
|
self.game.coop = false;
|
||||||
self.coop = false;
|
self.coop = false;
|
||||||
r3d.invalidate();
|
r3d.invalidate();
|
||||||
@@ -454,7 +687,7 @@ impl App {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll_match(&mut self) {
|
fn poll_match(&mut self, now: f64) {
|
||||||
if self.ws_id <= 0 {
|
if self.ws_id <= 0 {
|
||||||
if self.client.connected {
|
if self.client.connected {
|
||||||
self.client.on_disconnect();
|
self.client.on_disconnect();
|
||||||
@@ -462,8 +695,9 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while let Some(msg) = platform::ws_recv(self.ws_id) {
|
while let Some(msg) = platform::ws_recv(self.ws_id) {
|
||||||
self.client.feed(&msg);
|
self.client.feed_at(&msg, now);
|
||||||
}
|
}
|
||||||
|
self.client.tick_ping(now);
|
||||||
if self.client.phase() == Phase::Closed && self.game.in_match() {
|
if self.client.phase() == Phase::Closed && self.game.in_match() {
|
||||||
self.game.end_match();
|
self.game.end_match();
|
||||||
self.match_mode = false;
|
self.match_mode = false;
|
||||||
@@ -522,7 +756,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
game.ai_difficulty = info.difficulty;
|
game.ai_difficulty = info.difficulty;
|
||||||
game.sync_ai_sides(&info.ai, info.difficulty);
|
game.sync_ai_sides(&info.ai, info.difficulty);
|
||||||
game.hints = self.settings.hints;
|
apply_game_settings(&mut game, &self.settings);
|
||||||
self.game = game;
|
self.game = game;
|
||||||
let (cx, cy) = self.game.map.core;
|
let (cx, cy) = self.game.map.core;
|
||||||
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||||||
@@ -542,7 +776,7 @@ impl App {
|
|||||||
self.game.coop = false;
|
self.game.coop = false;
|
||||||
self.game.ai_difficulty = info.difficulty;
|
self.game.ai_difficulty = info.difficulty;
|
||||||
self.game.sync_ai_sides(&info.ai, info.difficulty);
|
self.game.sync_ai_sides(&info.ai, info.difficulty);
|
||||||
self.game.hints = self.settings.hints;
|
apply_game_settings(&mut self.game, &self.settings);
|
||||||
self.game.init_fog();
|
self.game.init_fog();
|
||||||
let (cx, cy) = self.game.map.core;
|
let (cx, cy) = self.game.map.core;
|
||||||
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||||||
@@ -602,9 +836,9 @@ async fn main() {
|
|||||||
let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
|
let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
|
||||||
// behind the title menu: the endless bridge battle
|
// behind the title menu: the endless bridge battle
|
||||||
let (game, cam) = Game::new_demo(seed);
|
let (game, cam) = Game::new_demo(seed);
|
||||||
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer() };
|
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None };
|
||||||
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
||||||
app.game.hints = app.settings.hints;
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
|
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
|
||||||
let mut touch = TouchInput::new();
|
let mut touch = TouchInput::new();
|
||||||
let mut auto_touch = platform::coarse_pointer();
|
let mut auto_touch = platform::coarse_pointer();
|
||||||
@@ -621,6 +855,10 @@ async fn main() {
|
|||||||
let now = get_time();
|
let now = get_time();
|
||||||
let fps_now = 1.0 / raw_dt.max(0.001);
|
let fps_now = 1.0 / raw_dt.max(0.001);
|
||||||
app.fps = if app.fps <= 0.0 { fps_now } else { app.fps + (fps_now - app.fps) * 0.08 };
|
app.fps = if app.fps <= 0.0 { fps_now } else { app.fps + (fps_now - app.fps) * 0.08 };
|
||||||
|
let raw_ms = raw_dt * 1000.0;
|
||||||
|
app.frame_ms = if app.frame_ms <= 0.0 { raw_ms } else { app.frame_ms + (raw_ms - app.frame_ms) * 0.08 };
|
||||||
|
app.frame_hist[app.frame_hist_pos] = raw_ms;
|
||||||
|
app.frame_hist_pos = (app.frame_hist_pos + 1) % FRAME_HIST;
|
||||||
let mut settings_changed = false;
|
let mut settings_changed = false;
|
||||||
|
|
||||||
// ---------------- touch or mouse: which interface is active
|
// ---------------- touch or mouse: which interface is active
|
||||||
@@ -658,7 +896,25 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(bytes) = incoming {
|
if let Some(bytes) = incoming {
|
||||||
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
|
if menu.campaign_pending {
|
||||||
|
menu.campaign_pending = false;
|
||||||
|
match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) {
|
||||||
|
Ok(campaign) => {
|
||||||
|
let errors = campaign.validate();
|
||||||
|
if errors.is_empty() {
|
||||||
|
app.set_status(i18n::trf("campaign.loaded", &[campaign.name.clone()]), now);
|
||||||
|
menu.campaign_doc = campaign;
|
||||||
|
menu.campaign_selected = 0;
|
||||||
|
menu.page = Some(Page::CampaignEditor);
|
||||||
|
} else {
|
||||||
|
app.set_status(i18n::trf("campaign.invalid", &[errors.join(" | ")]), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => app.set_status(i18n::trf("campaign.invalid", &[e]), now),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- savegame service: finished requests
|
// ---------------- savegame service: finished requests
|
||||||
@@ -712,7 +968,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- multiplayer relay: receive messages, send queued requests, update the menu state
|
// ---------------- multiplayer relay: receive messages, send queued requests, update the menu state
|
||||||
app.poll_match();
|
app.poll_match(now);
|
||||||
menu.in_match = app.match_mode || app.game.in_match();
|
menu.in_match = app.match_mode || app.game.in_match();
|
||||||
if matches!(menu.page, Some(Page::Match { .. })) {
|
if matches!(menu.page, Some(Page::Match { .. })) {
|
||||||
menu.set_match_ui(app.client.ui());
|
menu.set_match_ui(app.client.ui());
|
||||||
@@ -871,7 +1127,7 @@ async fn main() {
|
|||||||
if is_key_pressed(KeyCode::N) && app.game.state != 0 {
|
if is_key_pressed(KeyCode::N) && app.game.state != 0 {
|
||||||
app.seed = next_seed(app.seed);
|
app.seed = next_seed(app.seed);
|
||||||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||||||
app.game.hints = app.settings.hints;
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !dialog && !menu.typing() {
|
if !dialog && !menu.typing() {
|
||||||
@@ -1035,7 +1291,7 @@ async fn main() {
|
|||||||
MenuAction::NewGame => {
|
MenuAction::NewGame => {
|
||||||
app.seed = next_seed(app.seed);
|
app.seed = next_seed(app.seed);
|
||||||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||||||
app.game.hints = app.settings.hints;
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
menu.page = None;
|
menu.page = None;
|
||||||
ui.log.clear();
|
ui.log.clear();
|
||||||
ui.show_help = true;
|
ui.show_help = true;
|
||||||
@@ -1079,7 +1335,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
MenuAction::StartMission(i) => {
|
MenuAction::StartMission(i) => {
|
||||||
(app.game, app.cam) = mission_game(i, &menu.progress);
|
(app.game, app.cam) = mission_game(i, &menu.progress);
|
||||||
app.game.hints = app.settings.hints;
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
app.game.coop = false;
|
app.game.coop = false;
|
||||||
app.coop = false;
|
app.coop = false;
|
||||||
app.recorded = false;
|
app.recorded = false;
|
||||||
@@ -1088,6 +1344,140 @@ async fn main() {
|
|||||||
ui.show_help = false;
|
ui.show_help = false;
|
||||||
autosave_t = 0.0;
|
autosave_t = 0.0;
|
||||||
}
|
}
|
||||||
|
MenuAction::CampaignNew => {
|
||||||
|
menu.campaign_doc = campaign_doc::CampaignDoc::default();
|
||||||
|
menu.campaign_doc.add_mission();
|
||||||
|
menu.campaign_selected = 0;
|
||||||
|
menu.page = Some(Page::CampaignEditor);
|
||||||
|
app.set_status(i18n::tr("campaign.new"), now);
|
||||||
|
}
|
||||||
|
MenuAction::CampaignAdd => {
|
||||||
|
menu.campaign_doc.add_mission();
|
||||||
|
menu.campaign_selected = menu.campaign_doc.missions.len().saturating_sub(1);
|
||||||
|
menu.page = Some(Page::CampaignEditor);
|
||||||
|
app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now);
|
||||||
|
}
|
||||||
|
MenuAction::CampaignOpen => {
|
||||||
|
menu.campaign_pending = true;
|
||||||
|
platform::open_file();
|
||||||
|
app.set_status(i18n::tr("campaign.choose"), now);
|
||||||
|
}
|
||||||
|
MenuAction::CampaignSave => {
|
||||||
|
let name = menu.campaign_doc.file_name();
|
||||||
|
platform::download(&name, menu.campaign_doc.save().as_bytes());
|
||||||
|
app.set_status(i18n::trf("campaign.saved", &[name]), now);
|
||||||
|
}
|
||||||
|
MenuAction::CampaignEditName => {
|
||||||
|
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) {
|
||||||
|
menu.campaign_doc.name = value;
|
||||||
|
app.set_status(i18n::tr("campaign.updated"), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::CampaignEditTitle => {
|
||||||
|
let i = menu.campaign_selected;
|
||||||
|
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.title.clone()) {
|
||||||
|
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_title"), &initial, 80) {
|
||||||
|
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||||
|
mission.title = value;
|
||||||
|
}
|
||||||
|
app.set_status(i18n::tr("campaign.updated"), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::CampaignEditGoals => {
|
||||||
|
let i = menu.campaign_selected;
|
||||||
|
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.goals.join(", ")) {
|
||||||
|
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_goals"), &initial, 120) {
|
||||||
|
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||||
|
mission.goals = campaign_doc::parse_list(&value);
|
||||||
|
}
|
||||||
|
app.set_status(i18n::tr("campaign.updated"), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::CampaignEditNext => {
|
||||||
|
let i = menu.campaign_selected;
|
||||||
|
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.next.clone()).unwrap_or_default();
|
||||||
|
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_next"), &initial, 48) {
|
||||||
|
let key = sanitize_campaign_key(&value);
|
||||||
|
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||||
|
mission.next = Some(key);
|
||||||
|
}
|
||||||
|
app.set_status(i18n::tr("campaign.updated"), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::CampaignEditBranches => {
|
||||||
|
let i = menu.campaign_selected;
|
||||||
|
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| campaign_doc::branches_to_text(&m.branches)) {
|
||||||
|
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_branches"), &initial, 180) {
|
||||||
|
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||||
|
mission.branches = campaign_doc::parse_branches(&value);
|
||||||
|
}
|
||||||
|
app.set_status(i18n::tr("campaign.updated"), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::CampaignEditScript => {
|
||||||
|
let i = menu.campaign_selected;
|
||||||
|
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.script.as_ref()).map(|s| s.source.clone()).unwrap_or_default();
|
||||||
|
if let Some(source) = prompt_optional_line(&i18n::tr("campaign.prompt_script"), &initial, 600) {
|
||||||
|
if source.trim().is_empty() {
|
||||||
|
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||||
|
mission.script = None;
|
||||||
|
}
|
||||||
|
app.set_status(i18n::tr("campaign.updated"), now);
|
||||||
|
} else if let Some(mission) = menu.campaign_doc.missions.get(i).cloned() {
|
||||||
|
match validate_script_source(&mission, &source) {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||||
|
mission.script = Some(campaign_doc::ScriptDoc { language: "rhai".into(), source });
|
||||||
|
}
|
||||||
|
app.set_status(i18n::tr("campaign.updated"), now);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
app.set_status(i18n::trf("msg.script_error", &vec![error]), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.set_status(i18n::tr("campaign.error_no_mission"), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::CampaignDelete(i) => {
|
||||||
|
if i < menu.campaign_doc.missions.len() {
|
||||||
|
menu.campaign_doc.missions.remove(i);
|
||||||
|
menu.campaign_selected = menu.campaign_selected.min(menu.campaign_doc.missions.len().saturating_sub(1));
|
||||||
|
if i == app.custom_mission_index.unwrap_or(usize::MAX) {
|
||||||
|
app.custom_campaign = None;
|
||||||
|
app.custom_progress = None;
|
||||||
|
app.custom_mission_index = None;
|
||||||
|
app.custom_next_index = None;
|
||||||
|
}
|
||||||
|
app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::CampaignStart(i) => {
|
||||||
|
if let Some(mission_doc) = menu.campaign_doc.missions.get(i) {
|
||||||
|
if menu.campaign_doc.validate().is_empty() {
|
||||||
|
let def = campaign::mission_def_from_doc(mission_doc);
|
||||||
|
(app.game, app.cam) = custom_mission_game(&def, i);
|
||||||
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
|
start_custom_script(&mut app.game, mission_doc);
|
||||||
|
app.game.coop = false;
|
||||||
|
app.coop = false;
|
||||||
|
app.recorded = false;
|
||||||
|
app.custom_campaign = Some(menu.campaign_doc.clone());
|
||||||
|
app.custom_progress = Some(campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc));
|
||||||
|
app.custom_mission_index = Some(i);
|
||||||
|
menu.page = None;
|
||||||
|
ui.log.clear();
|
||||||
|
ui.show_help = false;
|
||||||
|
autosave_t = 0.0;
|
||||||
|
} else {
|
||||||
|
app.set_status(i18n::tr("campaign.invalid"), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
|
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
|
||||||
MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL),
|
MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL),
|
||||||
a @ (MenuAction::Login(..)
|
a @ (MenuAction::Login(..)
|
||||||
@@ -1144,18 +1534,65 @@ async fn main() {
|
|||||||
UiAction::NewGame => {
|
UiAction::NewGame => {
|
||||||
app.seed = next_seed(app.seed);
|
app.seed = next_seed(app.seed);
|
||||||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||||||
app.game.hints = app.settings.hints;
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
app.game.coop = false;
|
app.game.coop = false;
|
||||||
app.coop = false;
|
app.coop = false;
|
||||||
}
|
}
|
||||||
// end of a mission: next mission (won) or try again (lost)
|
// end of a mission: next mission (won) or try again (lost)
|
||||||
UiAction::EndNext => {
|
UiAction::EndNext => {
|
||||||
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
if app.custom_campaign.is_some() {
|
||||||
|
let campaign = app.custom_campaign.clone();
|
||||||
|
let progress = app.custom_progress.clone();
|
||||||
|
if app.game.state == 1 {
|
||||||
|
if let Some(next) = app.custom_next_index {
|
||||||
|
if let Some(campaign) = campaign {
|
||||||
|
if next < campaign.missions.len() {
|
||||||
|
let def = campaign::mission_def_from_doc(&campaign.missions[next]);
|
||||||
|
(app.game, app.cam) = custom_mission_game(&def, next);
|
||||||
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
|
start_custom_script(&mut app.game, &campaign.missions[next]);
|
||||||
|
app.game.coop = false;
|
||||||
|
app.coop = false;
|
||||||
|
app.recorded = false;
|
||||||
|
let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign));
|
||||||
|
app.custom_campaign = Some(campaign);
|
||||||
|
app.custom_progress = Some(progress);
|
||||||
|
app.custom_mission_index = Some(next);
|
||||||
|
app.custom_next_index = None;
|
||||||
|
menu.page = None;
|
||||||
|
ui.log.clear();
|
||||||
|
ui.show_help = false;
|
||||||
|
autosave_t = 0.0;
|
||||||
|
} else {
|
||||||
|
menu.page = Some(Page::CampaignEditor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
menu.page = Some(Page::CampaignEditor);
|
||||||
|
}
|
||||||
|
} else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) {
|
||||||
|
if i < campaign.missions.len() {
|
||||||
|
let def = campaign::mission_def_from_doc(&campaign.missions[i]);
|
||||||
|
(app.game, app.cam) = custom_mission_game(&def, i);
|
||||||
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
|
start_custom_script(&mut app.game, &campaign.missions[i]);
|
||||||
|
app.game.coop = false;
|
||||||
|
app.coop = false;
|
||||||
|
app.recorded = false;
|
||||||
|
let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign));
|
||||||
|
app.custom_campaign = Some(campaign);
|
||||||
|
app.custom_progress = Some(progress);
|
||||||
|
app.custom_mission_index = Some(i);
|
||||||
|
app.custom_next_index = None;
|
||||||
|
ui.log.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||||||
if app.game.state == 1 {
|
if app.game.state == 1 {
|
||||||
menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign });
|
menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign });
|
||||||
} else {
|
} else {
|
||||||
(app.game, app.cam) = mission_game(i, &menu.progress);
|
(app.game, app.cam) = mission_game(i, &menu.progress);
|
||||||
app.game.hints = app.settings.hints;
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
app.recorded = false;
|
app.recorded = false;
|
||||||
ui.log.clear();
|
ui.log.clear();
|
||||||
}
|
}
|
||||||
@@ -1273,7 +1710,7 @@ async fn main() {
|
|||||||
if settings_changed {
|
if settings_changed {
|
||||||
apply_settings(&app.settings, &mut r3d);
|
apply_settings(&app.settings, &mut r3d);
|
||||||
store_settings(&app.settings);
|
store_settings(&app.settings);
|
||||||
app.game.hints = app.settings.hints;
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- update (single-player worlds stand still while menus or dialogs are open;
|
// ---------------- update (single-player worlds stand still while menus or dialogs are open;
|
||||||
@@ -1310,12 +1747,32 @@ async fn main() {
|
|||||||
sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now);
|
sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now);
|
||||||
}
|
}
|
||||||
// a won mission goes into the campaign progress (with the carry-over)
|
// a won mission goes into the campaign progress (with the carry-over)
|
||||||
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
|
if app.custom_campaign.is_none() {
|
||||||
if app.game.state != 0 {
|
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
|
||||||
app.recorded = true;
|
if app.game.state != 0 {
|
||||||
|
app.recorded = true;
|
||||||
|
if app.game.state == 1 {
|
||||||
|
menu.progress.set_done(i, campaign::carry_from(&app.game));
|
||||||
|
store_progress(&menu.progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if app.game.state != 0 && !app.recorded {
|
||||||
|
app.recorded = true;
|
||||||
|
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||||||
|
app.custom_mission_index = Some(i);
|
||||||
if app.game.state == 1 {
|
if app.game.state == 1 {
|
||||||
menu.progress.set_done(i, campaign::carry_from(&app.game));
|
let next_info = match &app.custom_campaign {
|
||||||
store_progress(&menu.progress);
|
Some(campaign) => Some(custom_next_choice(campaign, i, &app.game)),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let mission_count = app.custom_campaign.as_ref().map(|c| c.missions.len()).unwrap_or(0);
|
||||||
|
if let Some((next_i, choice)) = next_info {
|
||||||
|
if let Some(progress) = &mut app.custom_progress {
|
||||||
|
progress.mark_done(i, mission_count, choice);
|
||||||
|
}
|
||||||
|
app.custom_next_index = next_i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1327,6 +1784,7 @@ async fn main() {
|
|||||||
| Some(Page::KeyCapture { from_title: true, .. })
|
| Some(Page::KeyCapture { from_title: true, .. })
|
||||||
| Some(Page::NewGame)
|
| Some(Page::NewGame)
|
||||||
| Some(Page::Campaign)
|
| Some(Page::Campaign)
|
||||||
|
| Some(Page::CampaignEditor)
|
||||||
| Some(Page::Briefing { ingame: false, .. })
|
| Some(Page::Briefing { ingame: false, .. })
|
||||||
| Some(Page::Login)
|
| Some(Page::Login)
|
||||||
| Some(Page::Account)
|
| Some(Page::Account)
|
||||||
@@ -1396,12 +1854,16 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
|||||||
high: ui.tool == Tool::Dig,
|
high: ui.tool == Tool::Dig,
|
||||||
};
|
};
|
||||||
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
|
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
|
||||||
|
if app.settings.debug_trigger_zones || app.settings.debug_path_lines {
|
||||||
|
draw_debug_3d(app, now);
|
||||||
|
}
|
||||||
let title_screen = matches!(
|
let title_screen = matches!(
|
||||||
menu.page,
|
menu.page,
|
||||||
Some(Page::Title)
|
Some(Page::Title)
|
||||||
| Some(Page::Settings { from_title: true, .. })
|
| Some(Page::Settings { from_title: true, .. })
|
||||||
| Some(Page::KeyCapture { from_title: true, .. })
|
| Some(Page::KeyCapture { from_title: true, .. })
|
||||||
| Some(Page::Campaign)
|
| Some(Page::Campaign)
|
||||||
|
| Some(Page::CampaignEditor)
|
||||||
| Some(Page::Briefing { ingame: false, .. })
|
| Some(Page::Briefing { ingame: false, .. })
|
||||||
| Some(Page::Login)
|
| Some(Page::Login)
|
||||||
| Some(Page::Account)
|
| Some(Page::Account)
|
||||||
@@ -1428,7 +1890,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
|||||||
if !touch {
|
if !touch {
|
||||||
ui.draw_cursor(gfx, &app.game, now, blocked);
|
ui.draw_cursor(gfx, &app.game, now, blocked);
|
||||||
}
|
}
|
||||||
if app.settings.debug_fps {
|
if app.settings.debug_fps || app.settings.debug_frame_time || app.settings.debug_ping {
|
||||||
draw_debug_fps(app, gfx);
|
draw_debug_fps(app, gfx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1436,24 +1898,170 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
|||||||
fn draw_debug_fps(app: &App, gfx: &Gfx) {
|
fn draw_debug_fps(app: &App, gfx: &Gfx) {
|
||||||
ui::begin_ui();
|
ui::begin_ui();
|
||||||
let scale = ui::SMALL().max(1.0);
|
let scale = ui::SMALL().max(1.0);
|
||||||
let fps = app.fps.max(0.0).round() as i32;
|
let text_h = scale * 10.0 + 4.0;
|
||||||
let renderer = {
|
|
||||||
let r = app.renderer.trim();
|
if app.settings.debug_fps {
|
||||||
if r.is_empty() {
|
let fps = app.fps.max(0.0).round() as i32;
|
||||||
if cfg!(target_arch = "wasm32") {
|
let renderer = {
|
||||||
"WebGL"
|
let r = app.renderer.trim();
|
||||||
|
if r.is_empty() {
|
||||||
|
if cfg!(target_arch = "wasm32") {
|
||||||
|
"WebGL"
|
||||||
|
} else {
|
||||||
|
"OpenGL"
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
"OpenGL"
|
r
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
r
|
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
|
||||||
|
let head: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect();
|
||||||
|
let head_w = gfx.measure(&head, scale);
|
||||||
|
let head_x = (ui::sw() - head_w - 2.0).max(2.0);
|
||||||
|
draw_rectangle(head_x - 2.0, 0.0, head_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||||||
|
gfx.text(&head, head_x, 2.0, scale, GOLD);
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.settings.debug_frame_time {
|
||||||
|
let ms = app.frame_ms.max(0.0);
|
||||||
|
let graph_w = (ui::sw() * 0.42).clamp(80.0, 260.0).min(ui::sw() - 4.0).max(40.0);
|
||||||
|
let graph_h = (scale * 30.0).clamp(20.0, 52.0);
|
||||||
|
let frame_y = (ui::sh() - text_h - 2.0).max(12.0);
|
||||||
|
let graph_y = (frame_y - graph_h - 6.0).max(12.0);
|
||||||
|
let graph_x = (ui::sw() - graph_w - 2.0).max(2.0);
|
||||||
|
|
||||||
|
let mut max_ms = ms.max(16.7);
|
||||||
|
for v in app.frame_hist.iter() {
|
||||||
|
max_ms = max_ms.max(*v);
|
||||||
}
|
}
|
||||||
};
|
let max_ms = max_ms.max(16.7).max(ms * 1.3).min(100.0);
|
||||||
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
|
|
||||||
let line: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect();
|
draw_rectangle(graph_x - 2.0, graph_y - 2.0, graph_w + 4.0, graph_h + 4.0, Color::from_rgba(0, 0, 0, 170));
|
||||||
let w = gfx.measure(&line, scale);
|
let bar_w = graph_w / FRAME_HIST as f32;
|
||||||
let x = (ui::sw() - w - 2.0).max(2.0);
|
for k in 0..FRAME_HIST {
|
||||||
let y = 2.0;
|
let v = app.frame_hist[(app.frame_hist_pos + k) % FRAME_HIST];
|
||||||
draw_rectangle(x - 2.0, y - 2.0, w + 4.0, scale * 10.0 + 4.0, Color::from_rgba(0, 0, 0, 150));
|
if v <= 0.0 {
|
||||||
gfx.text(&line, x, y, scale, GOLD);
|
continue;
|
||||||
|
}
|
||||||
|
let h = graph_h * (v / max_ms).clamp(0.0, 1.0).max(0.08);
|
||||||
|
let col = if v <= 16.7 {
|
||||||
|
Color::from_rgba(90, 220, 120, 235)
|
||||||
|
} else if v <= 33.3 {
|
||||||
|
Color::from_rgba(240, 210, 100, 235)
|
||||||
|
} else {
|
||||||
|
Color::from_rgba(235, 90, 90, 235)
|
||||||
|
};
|
||||||
|
draw_rectangle(graph_x + k as f32 * bar_w, graph_y + graph_h - h, bar_w.max(1.0), h, col);
|
||||||
|
}
|
||||||
|
draw_rectangle(graph_x, graph_y + graph_h, graph_w, 1.0, Color::from_rgba(255, 255, 255, 50));
|
||||||
|
|
||||||
|
let frame_line = format!("{:.1} ms", ms);
|
||||||
|
let frame_w = gfx.measure(&frame_line, scale);
|
||||||
|
let frame_x = (ui::sw() - frame_w - 2.0).max(2.0);
|
||||||
|
draw_rectangle(frame_x - 2.0, frame_y - 2.0, frame_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||||||
|
gfx.text(&frame_line, frame_x, frame_y, scale, GOLD);
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.settings.debug_ping {
|
||||||
|
let ping = if app.client.connected { format!("{:.0} ms", app.client.ping_ms) } else { "no match".into() };
|
||||||
|
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
|
||||||
|
let line: String = format!("ping {}", ping).chars().take(max_cols).collect();
|
||||||
|
let line_w = gfx.measure(&line, scale);
|
||||||
|
draw_rectangle(0.0, ui::sh() - text_h - 2.0, line_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||||||
|
let color = if !app.client.connected {
|
||||||
|
ui::DIM
|
||||||
|
} else if app.client.ping_ms <= 120.0 {
|
||||||
|
ui::GREEN
|
||||||
|
} else if app.client.ping_ms <= 300.0 {
|
||||||
|
Color::from_rgba(240, 210, 100, 255)
|
||||||
|
} else {
|
||||||
|
ui::RED
|
||||||
|
};
|
||||||
|
gfx.text(&line, 2.0, ui::sh() - text_h, scale, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_debug_3d(app: &App, now: f64) {
|
||||||
|
let pulse = 0.35 + 0.12 * ((now * 2.5).sin() as f32);
|
||||||
|
|
||||||
|
if app.settings.debug_trigger_zones {
|
||||||
|
if let Some(campaign) = app.custom_campaign.as_ref() {
|
||||||
|
if let Some(index) = app.custom_mission_index {
|
||||||
|
if let Some(mission) = campaign.missions.get(index) {
|
||||||
|
let done = app.custom_progress.as_ref().map(|p| p.is_done(index)).unwrap_or(false);
|
||||||
|
let zone_pulse = if done { pulse * 0.45 } else { pulse };
|
||||||
|
for zone in mission.zones.iter().take(80) {
|
||||||
|
draw_debug_zone(zone, app.game.map.core, zone_pulse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.settings.debug_path_lines {
|
||||||
|
let mut drawn = 0usize;
|
||||||
|
for e in app.game.ents.iter() {
|
||||||
|
if !e.alive || e.held || e.hero || e.side != app.game.local_side || e.path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if drawn >= 120 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
drawn += 1;
|
||||||
|
let color = chat_color(Some(e.side), 0.55);
|
||||||
|
let mut prev = vec3(e.disp_x, 0.075, e.disp_y);
|
||||||
|
for tile in e.path.iter().take(12) {
|
||||||
|
let (tx, ty) = map::xy(*tile);
|
||||||
|
let p = vec3(tx as f32 + 0.5, 0.055, ty as f32 + 0.5);
|
||||||
|
line3(prev, p, 0.04, color);
|
||||||
|
prev = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_debug_zone(zone: &campaign_doc::ZoneDoc, core: (i32, i32), pulse: f32) {
|
||||||
|
let c = debug_zone_color(&zone.name);
|
||||||
|
let color = Color::new(c.r, c.g, c.b, pulse);
|
||||||
|
let at = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
|
||||||
|
let shape = zone.shape.as_str();
|
||||||
|
|
||||||
|
match shape {
|
||||||
|
"rect" | "rectangle" | "square" => {
|
||||||
|
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||||||
|
let a = vec3(from.0 + 0.5, 0.05, from.1 + 0.5);
|
||||||
|
let b = vec3(to.0 + 0.5, 0.05, to.1 + 0.5);
|
||||||
|
line3(vec3(a.x, 0.05, a.y), vec3(b.x, 0.05, a.y), 0.05, color);
|
||||||
|
line3(vec3(b.x, 0.05, a.y), vec3(b.x, 0.05, b.y), 0.05, color);
|
||||||
|
line3(vec3(b.x, 0.05, b.y), vec3(a.x, 0.05, b.y), 0.05, color);
|
||||||
|
line3(vec3(a.x, 0.05, b.y), vec3(a.x, 0.05, a.y), 0.05, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"line" | "path" => {
|
||||||
|
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||||||
|
let w = zone.width.unwrap_or(1.0).max(0.2);
|
||||||
|
line3(vec3(from.0 + 0.5, 0.05, from.1 + 0.5), vec3(to.0 + 0.5, 0.05, to.1 + 0.5), w * 0.05, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tiles" => {
|
||||||
|
if let Some(tiles) = zone.tiles.as_ref() {
|
||||||
|
for (x, y) in tiles.iter().take(200) {
|
||||||
|
disc(vec3(*x as f32 + 0.5, 0.045, *y as f32 + 0.5), 0.45, color, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let r = zone.radius.unwrap_or(2.0);
|
||||||
|
disc(vec3(at.0 + 0.5, 0.04, at.1 + 0.5), r, color, 48);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_zone_color(name: &str) -> Color {
|
||||||
|
let mut h: u64 = 2166136261;
|
||||||
|
for b in name.bytes() {
|
||||||
|
h ^= b as u64;
|
||||||
|
h = h.wrapping_mul(1099511628211);
|
||||||
|
}
|
||||||
|
Color::from_rgba(80 + ((h % 176) as u8), 90 + (((h >> 8) % 166) as u8), 120 + (((h >> 16) % 136) as u8), 255)
|
||||||
}
|
}
|
||||||
+231
-8
@@ -3,6 +3,7 @@
|
|||||||
// Drawn as CP437 windows on top of the (paused) game world.
|
// Drawn as CP437 windows on top of the (paused) game world.
|
||||||
|
|
||||||
use crate::campaign::{missions, Progress};
|
use crate::campaign::{missions, Progress};
|
||||||
|
use crate::campaign_doc::CampaignDoc;
|
||||||
use crate::cloud::{date_string, duration_string, Cloud};
|
use crate::cloud::{date_string, duration_string, Cloud};
|
||||||
use crate::game::Goal;
|
use crate::game::Goal;
|
||||||
use crate::gfx::*;
|
use crate::gfx::*;
|
||||||
@@ -24,6 +25,7 @@ pub enum Page {
|
|||||||
KeyCapture { action: usize, from_title: bool },
|
KeyCapture { action: usize, from_title: bool },
|
||||||
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
|
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
|
||||||
Campaign, // list of the campaign missions
|
Campaign, // list of the campaign missions
|
||||||
|
CampaignEditor, // editable shareable .dccamp campaign file
|
||||||
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
|
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
|
||||||
Login, // login to the savegame service (before the title)
|
Login, // login to the savegame service (before the title)
|
||||||
Account, // password, logout, users (admin)
|
Account, // password, logout, users (admin)
|
||||||
@@ -31,7 +33,7 @@ pub enum Page {
|
|||||||
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
|
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"];
|
pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"];
|
||||||
|
|
||||||
// One adjustable value in the settings dialog
|
// One adjustable value in the settings dialog
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
@@ -51,6 +53,11 @@ pub enum Item {
|
|||||||
UiScale,
|
UiScale,
|
||||||
RenderScale,
|
RenderScale,
|
||||||
DebugFps,
|
DebugFps,
|
||||||
|
DebugFrameTime,
|
||||||
|
DebugPing,
|
||||||
|
DebugTriggerZones,
|
||||||
|
DebugCommandMarkers,
|
||||||
|
DebugPathLines,
|
||||||
GameSpeed,
|
GameSpeed,
|
||||||
Hints,
|
Hints,
|
||||||
TouchUi,
|
TouchUi,
|
||||||
@@ -63,7 +70,8 @@ fn tab_items(tab: usize) -> &'static [Item] {
|
|||||||
0 => &[Language, Volume, MusicVolume, Mute, Voice],
|
0 => &[Language, Volume, MusicVolume, Mute, Voice],
|
||||||
1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse],
|
1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse],
|
||||||
2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale],
|
2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale],
|
||||||
3 => &[GameSpeed, Hints, TouchUi, DebugFps],
|
3 => &[GameSpeed, Hints, TouchUi],
|
||||||
|
4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines],
|
||||||
_ => &[],
|
_ => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +93,11 @@ fn item_label(it: Item) -> String {
|
|||||||
Item::UiScale => "set.ui_scale",
|
Item::UiScale => "set.ui_scale",
|
||||||
Item::RenderScale => "set.render_scale",
|
Item::RenderScale => "set.render_scale",
|
||||||
Item::DebugFps => "set.debug_fps",
|
Item::DebugFps => "set.debug_fps",
|
||||||
|
Item::DebugFrameTime => "set.debug_frame_time",
|
||||||
|
Item::DebugPing => "set.debug_ping",
|
||||||
|
Item::DebugTriggerZones => "set.debug_trigger_zones",
|
||||||
|
Item::DebugCommandMarkers => "set.debug_command_markers",
|
||||||
|
Item::DebugPathLines => "set.debug_path_lines",
|
||||||
Item::GameSpeed => "set.game_speed",
|
Item::GameSpeed => "set.game_speed",
|
||||||
Item::Hints => "set.hints",
|
Item::Hints => "set.hints",
|
||||||
Item::TouchUi => "set.touch_ui",
|
Item::TouchUi => "set.touch_ui",
|
||||||
@@ -127,6 +140,11 @@ fn item_value(it: Item, s: &Settings) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Item::DebugFps => onoff(s.debug_fps),
|
Item::DebugFps => onoff(s.debug_fps),
|
||||||
|
Item::DebugFrameTime => onoff(s.debug_frame_time),
|
||||||
|
Item::DebugPing => onoff(s.debug_ping),
|
||||||
|
Item::DebugTriggerZones => onoff(s.debug_trigger_zones),
|
||||||
|
Item::DebugCommandMarkers => onoff(s.debug_command_markers),
|
||||||
|
Item::DebugPathLines => onoff(s.debug_path_lines),
|
||||||
Item::RenderScale => {
|
Item::RenderScale => {
|
||||||
// the setting is for a 1080 pixel high window; show what applies to this one
|
// the setting is for a 1080 pixel high window; show what applies to this one
|
||||||
let now = crate::render3d::effective_div(s.render_scale.max(1) as u32, crate::ui::sh());
|
let now = crate::render3d::effective_div(s.render_scale.max(1) as u32, crate::ui::sh());
|
||||||
@@ -178,6 +196,11 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
|
|||||||
Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d),
|
Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d),
|
||||||
Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::<Vec<u8>>(), s.render_scale.clamp(1, 16), d),
|
Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::<Vec<u8>>(), s.render_scale.clamp(1, 16), d),
|
||||||
Item::DebugFps => s.debug_fps = !s.debug_fps,
|
Item::DebugFps => s.debug_fps = !s.debug_fps,
|
||||||
|
Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time,
|
||||||
|
Item::DebugPing => s.debug_ping = !s.debug_ping,
|
||||||
|
Item::DebugTriggerZones => s.debug_trigger_zones = !s.debug_trigger_zones,
|
||||||
|
Item::DebugCommandMarkers => s.debug_command_markers = !s.debug_command_markers,
|
||||||
|
Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines,
|
||||||
Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
|
Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
|
||||||
Item::Hints => s.hints = !s.hints,
|
Item::Hints => s.hints = !s.hints,
|
||||||
Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d),
|
Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d),
|
||||||
@@ -218,6 +241,20 @@ enum Btn {
|
|||||||
FileLoad,
|
FileLoad,
|
||||||
NameField,
|
NameField,
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignEditor,
|
||||||
|
CampaignNew,
|
||||||
|
CampaignOpen,
|
||||||
|
CampaignSave,
|
||||||
|
CampaignAdd,
|
||||||
|
CampaignDelete,
|
||||||
|
CampaignEditName,
|
||||||
|
CampaignEditTitle,
|
||||||
|
CampaignEditGoals,
|
||||||
|
CampaignEditNext,
|
||||||
|
CampaignEditBranches,
|
||||||
|
CampaignEditScript,
|
||||||
|
CampaignStart,
|
||||||
|
CampaignMission(usize),
|
||||||
Mission(usize),
|
Mission(usize),
|
||||||
StartMission,
|
StartMission,
|
||||||
Goals,
|
Goals,
|
||||||
@@ -302,6 +339,18 @@ pub enum MenuAction {
|
|||||||
CloudDelete(String),
|
CloudDelete(String),
|
||||||
EditName, // the name field was tapped (touch: ask with a browser dialog)
|
EditName, // the name field was tapped (touch: ask with a browser dialog)
|
||||||
StartMission(usize),
|
StartMission(usize),
|
||||||
|
CampaignNew,
|
||||||
|
CampaignOpen,
|
||||||
|
CampaignSave,
|
||||||
|
CampaignAdd,
|
||||||
|
CampaignDelete(usize),
|
||||||
|
CampaignEditName,
|
||||||
|
CampaignEditTitle,
|
||||||
|
CampaignEditGoals,
|
||||||
|
CampaignEditNext,
|
||||||
|
CampaignEditBranches,
|
||||||
|
CampaignEditScript,
|
||||||
|
CampaignStart(usize),
|
||||||
OpenRepo,
|
OpenRepo,
|
||||||
OpenClaudeCode,
|
OpenClaudeCode,
|
||||||
Login(String, String),
|
Login(String, String),
|
||||||
@@ -339,6 +388,9 @@ pub struct Menu {
|
|||||||
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
|
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
|
||||||
pub coop_mission: usize, // co-op campaign mission selected on the Match page
|
pub coop_mission: usize, // co-op campaign mission selected on the Match page
|
||||||
pub in_match: bool, // a match is being played; the pause menu changes its shape
|
pub in_match: bool, // a match is being played; the pause menu changes its shape
|
||||||
|
pub campaign_doc: CampaignDoc,
|
||||||
|
pub campaign_selected: usize,
|
||||||
|
pub campaign_pending: bool,
|
||||||
confirm: Option<Btn>, // overwrite / delete waiting for a second click
|
confirm: Option<Btn>, // overwrite / delete waiting for a second click
|
||||||
scroll: usize,
|
scroll: usize,
|
||||||
rows_visible: usize,
|
rows_visible: usize,
|
||||||
@@ -366,6 +418,9 @@ impl Menu {
|
|||||||
match_ui: MatchUi::default(),
|
match_ui: MatchUi::default(),
|
||||||
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
|
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
|
||||||
in_match: false,
|
in_match: false,
|
||||||
|
campaign_doc: CampaignDoc::default(),
|
||||||
|
campaign_selected: 0,
|
||||||
|
campaign_pending: false,
|
||||||
confirm: None,
|
confirm: None,
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
rows_visible: 1,
|
rows_visible: 1,
|
||||||
@@ -380,13 +435,14 @@ impl Menu {
|
|||||||
// Esc: go one level back
|
// Esc: go one level back
|
||||||
pub fn back(&mut self) {
|
pub fn back(&mut self) {
|
||||||
self.page = match self.page {
|
self.page = match self.page {
|
||||||
Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 4, from_title }),
|
Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 5, from_title }),
|
||||||
Some(Page::Settings { from_title: true, .. }) => Some(Page::Title),
|
Some(Page::Settings { from_title: true, .. }) => Some(Page::Title),
|
||||||
Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause),
|
Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause),
|
||||||
Some(Page::NewGame) => Some(Page::Title),
|
Some(Page::NewGame) => Some(Page::Title),
|
||||||
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
|
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
|
||||||
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
|
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
|
||||||
Some(Page::Campaign) => Some(Page::Title),
|
Some(Page::Campaign) => Some(Page::Title),
|
||||||
|
Some(Page::CampaignEditor) => Some(Page::Title),
|
||||||
Some(Page::Login) => Some(Page::Login),
|
Some(Page::Login) => Some(Page::Login),
|
||||||
Some(Page::Account) => Some(Page::Title),
|
Some(Page::Account) => Some(Page::Title),
|
||||||
Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
|
Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
|
||||||
@@ -488,7 +544,7 @@ impl Menu {
|
|||||||
|
|
||||||
pub fn finish_capture(&mut self) {
|
pub fn finish_capture(&mut self) {
|
||||||
if let Some(Page::KeyCapture { from_title, .. }) = self.page {
|
if let Some(Page::KeyCapture { from_title, .. }) = self.page {
|
||||||
self.page = Some(Page::Settings { tab: 4, from_title });
|
self.page = Some(Page::Settings { tab: 5, from_title });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,6 +725,36 @@ impl Menu {
|
|||||||
self.scroll = 0;
|
self.scroll = 0;
|
||||||
MenuAction::Consumed
|
MenuAction::Consumed
|
||||||
}
|
}
|
||||||
|
Some(Btn::CampaignEditor) => {
|
||||||
|
self.page = Some(Page::CampaignEditor);
|
||||||
|
self.scroll = 0;
|
||||||
|
MenuAction::Consumed
|
||||||
|
}
|
||||||
|
Some(Btn::CampaignNew) => MenuAction::CampaignNew,
|
||||||
|
Some(Btn::CampaignOpen) => MenuAction::CampaignOpen,
|
||||||
|
Some(Btn::CampaignSave) => MenuAction::CampaignSave,
|
||||||
|
Some(Btn::CampaignAdd) => MenuAction::CampaignAdd,
|
||||||
|
Some(Btn::CampaignEditName) => MenuAction::CampaignEditName,
|
||||||
|
Some(Btn::CampaignEditTitle) => MenuAction::CampaignEditTitle,
|
||||||
|
Some(Btn::CampaignEditGoals) => MenuAction::CampaignEditGoals,
|
||||||
|
Some(Btn::CampaignEditNext) => MenuAction::CampaignEditNext,
|
||||||
|
Some(Btn::CampaignEditBranches) => MenuAction::CampaignEditBranches,
|
||||||
|
Some(Btn::CampaignEditScript) => MenuAction::CampaignEditScript,
|
||||||
|
Some(b @ Btn::CampaignDelete) => {
|
||||||
|
let i = self.campaign_selected;
|
||||||
|
if confirm == Some(b) {
|
||||||
|
MenuAction::CampaignDelete(i)
|
||||||
|
} else {
|
||||||
|
self.confirm = Some(b);
|
||||||
|
MenuAction::Consumed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Btn::CampaignStart) => MenuAction::CampaignStart(self.campaign_selected),
|
||||||
|
Some(Btn::CampaignMission(i)) => {
|
||||||
|
self.campaign_selected = i;
|
||||||
|
self.scroll = 0;
|
||||||
|
MenuAction::Consumed
|
||||||
|
}
|
||||||
Some(Btn::Mission(i)) => {
|
Some(Btn::Mission(i)) => {
|
||||||
self.page = Some(Page::Briefing { mission: i, ingame: false });
|
self.page = Some(Page::Briefing { mission: i, ingame: false });
|
||||||
self.scroll = 0;
|
self.scroll = 0;
|
||||||
@@ -711,13 +797,14 @@ impl Menu {
|
|||||||
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
|
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
|
||||||
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
|
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
|
||||||
Page::Campaign => self.draw_campaign(gfx),
|
Page::Campaign => self.draw_campaign(gfx),
|
||||||
|
Page::CampaignEditor => self.draw_campaign_editor(gfx),
|
||||||
Page::Login => self.draw_login(gfx, cloud),
|
Page::Login => self.draw_login(gfx, cloud),
|
||||||
Page::Account => self.draw_account(gfx, cloud),
|
Page::Account => self.draw_account(gfx, cloud),
|
||||||
Page::Match { .. } => self.draw_match(gfx, cloud),
|
Page::Match { .. } => self.draw_match(gfx, cloud),
|
||||||
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
|
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
|
||||||
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
|
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
|
||||||
Page::KeyCapture { action, .. } => {
|
Page::KeyCapture { action, .. } => {
|
||||||
self.draw_settings(gfx, settings, 4);
|
self.draw_settings(gfx, settings, 5);
|
||||||
self.buttons.clear();
|
self.buttons.clear();
|
||||||
let a = all_actions()[action];
|
let a = all_actions()[action];
|
||||||
let text = trf("set.press_key", &[action_label(a)]);
|
let text = trf("set.press_key", &[action_label(a)]);
|
||||||
@@ -770,14 +857,15 @@ impl Menu {
|
|||||||
items.push((Btn::Continue, tr("menu.continue")));
|
items.push((Btn::Continue, tr("menu.continue")));
|
||||||
}
|
}
|
||||||
items.push((Btn::Campaign, tr("menu.campaign")));
|
items.push((Btn::Campaign, tr("menu.campaign")));
|
||||||
|
items.push((Btn::CampaignEditor, tr("menu.campaign_editor")));
|
||||||
items.push((Btn::Match, tr("menu.multiplayer")));
|
items.push((Btn::Match, tr("menu.multiplayer")));
|
||||||
items.push((Btn::New, tr("menu.new")));
|
items.push((Btn::New, tr("menu.new")));
|
||||||
items.push((Btn::Load, tr("menu.load")));
|
items.push((Btn::Load, tr("menu.load")));
|
||||||
items.push((Btn::Settings, tr("menu.settings")));
|
items.push((Btn::Settings, tr("menu.settings")));
|
||||||
if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) {
|
if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) {
|
||||||
items.push((Btn::Account, trf("menu.account", &[s.name.clone()])));
|
items.push((Btn::Account, trf("menu.account", &[s.name.clone()])));
|
||||||
items.push((Btn::Standalone, tr("menu.standalone")));
|
|
||||||
}
|
}
|
||||||
|
items.push((Btn::Standalone, tr("menu.standalone")));
|
||||||
items.push((Btn::Bestiary, tr("menu.bestiary")));
|
items.push((Btn::Bestiary, tr("menu.bestiary")));
|
||||||
items.push((Btn::Help, tr("menu.help")));
|
items.push((Btn::Help, tr("menu.help")));
|
||||||
let w = 26.0 * CW();
|
let w = 26.0 * CW();
|
||||||
@@ -928,7 +1016,7 @@ impl Menu {
|
|||||||
tx += w + CW();
|
tx += w + CW();
|
||||||
}
|
}
|
||||||
let top = ty + CH() * 3.5;
|
let top = ty + CH() * 3.5;
|
||||||
if tab == 4 {
|
if tab == 5 {
|
||||||
// key bindings in two columns
|
// key bindings in two columns
|
||||||
let acts = all_actions();
|
let acts = all_actions();
|
||||||
let half = (acts.len() + 1) / 2;
|
let half = (acts.len() + 1) / 2;
|
||||||
@@ -1148,6 +1236,141 @@ impl Menu {
|
|||||||
self.buttons.push((br, Btn::Back));
|
self.buttons.push((br, Btn::Back));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First campaign-editor step: load/save shareable missions and show their branching structure.
|
||||||
|
fn draw_campaign_editor(&mut self, gfx: &Gfx) {
|
||||||
|
let sw = sw();
|
||||||
|
let sh = sh();
|
||||||
|
let (mx, my) = mouse();
|
||||||
|
let cols = ((sw / CW()) as i32 - 2).min(72);
|
||||||
|
let rows = ((sh / CH()) as i32 - 2).min(34);
|
||||||
|
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
|
||||||
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
||||||
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("campaign.title"), FRAME_HI);
|
||||||
|
|
||||||
|
let campaign = self.campaign_doc.clone();
|
||||||
|
let errors = campaign.validate();
|
||||||
|
let name = if campaign.name.trim().is_empty() { tr("campaign.new") } else { campaign.name.clone() };
|
||||||
|
let file = campaign.file_name();
|
||||||
|
let n = campaign.missions.len();
|
||||||
|
let inner_x = x + CW() * 2.0;
|
||||||
|
let inner_w = (cols - 4) as f32 * CW();
|
||||||
|
let head = trf("campaign.name", &[name]);
|
||||||
|
let head_w = (gfx.measure(&head, S()) + CW()).max(CW() * 8.0);
|
||||||
|
let head_rect = Rect::new(inner_x, y + CH() * 1.5, head_w, GH * S() + CH() * 0.5);
|
||||||
|
let hot_name = head_rect.contains(vec2(mx, my));
|
||||||
|
gfx.text(&head, inner_x, y + CH() * 1.5, S(), if hot_name { FRAME_HI } else { GOLD });
|
||||||
|
if !campaign.author.trim().is_empty() {
|
||||||
|
let author = trf("campaign.author", &[campaign.author.clone()]);
|
||||||
|
gfx.text(&author, inner_x + head_w + CW() * 2.0, y + CH() * 1.5, S(), DIM);
|
||||||
|
}
|
||||||
|
self.buttons.push((head_rect, Btn::CampaignEditName));
|
||||||
|
|
||||||
|
let top = if n > 0 { y + CH() * 5.0 } else { y + CH() * 3.5 };
|
||||||
|
if n > 0 {
|
||||||
|
let field_items = [(Btn::CampaignEditTitle, "T"), (Btn::CampaignEditGoals, "G"), (Btn::CampaignEditNext, "N"), (Btn::CampaignEditBranches, "B"), (Btn::CampaignEditScript, "S")];
|
||||||
|
let widths: Vec<f32> = field_items.iter().map(|(_, l)| (gfx.measure(l, S()) + CW() * 2.5).max(CW() * 3.5)).collect();
|
||||||
|
let total_w = widths.iter().sum::<f32>() + (field_items.len() as f32 - 1.0) * CW() * 0.5;
|
||||||
|
let mut fx = x + cols as f32 * CW() - CW() * 2.0 - total_w;
|
||||||
|
for (k, (b, l)) in field_items.iter().enumerate() {
|
||||||
|
let r = Rect::new(fx, y + CH() * 2.8, widths[k], CH() * 1.8);
|
||||||
|
button(gfx, r, l, r.contains(vec2(mx, my)), FRAME);
|
||||||
|
self.buttons.push((r, *b));
|
||||||
|
fx += widths[k] + CW() * 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bottom = y + (rows - 5) as f32 * CH();
|
||||||
|
let row_h = CH() * 3.0;
|
||||||
|
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
|
||||||
|
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
|
||||||
|
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
gfx.text(&tr("campaign.empty"), inner_x, top, S(), DIM);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (k, mission) in campaign.missions.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
|
||||||
|
let ry = top + (k - self.scroll) as f32 * row_h;
|
||||||
|
let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, row_h - CH() * 0.5);
|
||||||
|
let selected = k == self.campaign_selected;
|
||||||
|
let hot = r.contains(vec2(mx, my));
|
||||||
|
draw_rectangle(r.x, r.y, r.w, r.h, if selected { Color::new(0.32, 0.21, 0.1, 1.0) } else if hot { Color::new(0.18, 0.12, 0.08, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
|
||||||
|
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if selected { GOLD } else if hot { FRAME_HI } else { FRAME });
|
||||||
|
let title = if mission.title.trim().is_empty() { mission.key.clone() } else { mission.title.clone() };
|
||||||
|
let line = trf("campaign.mission_no", &[(k + 1).to_string(), title]);
|
||||||
|
let maxc = ((r.w - CW() * 2.0) / CW()) as usize;
|
||||||
|
let line: String = line.chars().take(maxc).collect();
|
||||||
|
gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if selected { GOLD } else { TXT });
|
||||||
|
|
||||||
|
let next = mission.next.clone().unwrap_or_default();
|
||||||
|
let branch_count = mission.branches.len();
|
||||||
|
let detail = if branch_count > 0 {
|
||||||
|
trf("campaign.branches", &[branch_count.to_string()])
|
||||||
|
} else if !next.is_empty() {
|
||||||
|
trf("campaign.next", &[next])
|
||||||
|
} else if !mission.goals.is_empty() {
|
||||||
|
trf("campaign.goals", &[mission.goals.join(", ")])
|
||||||
|
} else {
|
||||||
|
tr("campaign.empty")
|
||||||
|
};
|
||||||
|
let script_count = mission.script.as_ref().map(|s| if s.source.trim().is_empty() { 0 } else { 1 }).unwrap_or(0);
|
||||||
|
let zone_count = mission.zones.len();
|
||||||
|
let detail = if zone_count > 0 || script_count > 0 {
|
||||||
|
format!("{} · {} · {}", detail, trf("campaign.zones", &[zone_count.to_string()]), trf("campaign.script", &[script_count.to_string()]))
|
||||||
|
} else {
|
||||||
|
detail
|
||||||
|
};
|
||||||
|
let maxc = ((r.w - CW() * 2.0) / GW.max(1.0) / (SMALL().max(1.0))) as usize;
|
||||||
|
let detail: String = detail.chars().take(maxc.max(20)).collect();
|
||||||
|
gfx.text(&detail, r.x + CW(), r.y + CH() * 1.6, SMALL(), DIM);
|
||||||
|
self.buttons.push((r, Btn::CampaignMission(k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if scroll_w > 0.0 {
|
||||||
|
let sx = inner_x + inner_w - CW() * 2.5;
|
||||||
|
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
|
||||||
|
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
|
||||||
|
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
|
||||||
|
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
|
||||||
|
self.buttons.push((up, Btn::Scroll(-1)));
|
||||||
|
self.buttons.push((down, Btn::Scroll(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = trf("campaign.missions", &[n.to_string()]);
|
||||||
|
gfx.text(&count, inner_x, bottom + CH() * 0.5, SMALL(), DIM);
|
||||||
|
let file_line = format!("{} · {}", file, tr("menu.drop_hint"));
|
||||||
|
let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize;
|
||||||
|
let file_line: String = file_line.chars().take(maxc.max(20)).collect();
|
||||||
|
gfx.text(&file_line, x + cols as f32 * CW() - gfx.measure(&file_line, SMALL()) - CW() * 2.0, bottom + CH() * 0.5, SMALL(), DIM);
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
let err = errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join(" | ");
|
||||||
|
let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize;
|
||||||
|
for l in wrap(&err, maxc).iter().take(3) {
|
||||||
|
gfx.text(l, inner_x, bottom + CH() * 1.5, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let by = y + (rows as f32 - 3.5) * CH();
|
||||||
|
let right = x + cols as f32 * CW() - CW() * 2.0;
|
||||||
|
let mut bx = inner_x;
|
||||||
|
let mut actions = vec![(Btn::CampaignNew, tr("campaign.new")), (Btn::CampaignAdd, tr("campaign.add"))];
|
||||||
|
if n > 0 {
|
||||||
|
actions.push((Btn::CampaignStart, tr("campaign.start")));
|
||||||
|
actions.push((Btn::CampaignDelete, tr("campaign.delete")));
|
||||||
|
}
|
||||||
|
actions.extend([(Btn::CampaignOpen, tr("campaign.open")), (Btn::CampaignSave, tr("campaign.save")), (Btn::Back, tr("menu.back"))]);
|
||||||
|
for (b, label) in actions {
|
||||||
|
let w = (gfx.measure(&label, S()) + CW() * 4.0).max(CW() * 6.0);
|
||||||
|
let r = Rect::new(bx, by, w, CH() * 2.0);
|
||||||
|
if r.x + r.w <= right {
|
||||||
|
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
|
||||||
|
self.buttons.push((r, b));
|
||||||
|
}
|
||||||
|
bx += w + CW();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Briefing: the old imp tells the story, then the goals and what comes along
|
// Briefing: the old imp tells the story, then the goals and what comes along
|
||||||
fn draw_briefing(&mut self, gfx: &Gfx, mission: usize, ingame: bool) {
|
fn draw_briefing(&mut self, gfx: &Gfx, mission: usize, ingame: bool) {
|
||||||
let sw = sw();
|
let sw = sw();
|
||||||
@@ -1236,7 +1459,7 @@ impl Menu {
|
|||||||
|
|
||||||
// scrolling on pages with lists (mouse wheel)
|
// scrolling on pages with lists (mouse wheel)
|
||||||
pub fn scrollable(&self) -> bool {
|
pub fn scrollable(&self) -> bool {
|
||||||
matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::Briefing { .. }))
|
matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scroll_lines(&mut self, d: i32) {
|
pub fn scroll_lines(&mut self, d: i32) {
|
||||||
|
|||||||
+4
-1
@@ -2231,11 +2231,14 @@ fn draw_marks(g: &Game, t: f32) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for m in &g.marks {
|
for m in &g.marks {
|
||||||
|
if !m.fixed && !g.debug_markers {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let left = m.until.saturating_sub(g.sim_steps) as f32 * STEP;
|
let left = m.until.saturating_sub(g.sim_steps) as f32 * STEP;
|
||||||
if left <= 0.0 {
|
if left <= 0.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let a = (left / 2.0).min(1.0).max(0.18);
|
let a = if m.fixed { 1.0 } else { (left / 2.0).min(1.0).max(0.18) };
|
||||||
let base = mark_color(m.kind);
|
let base = mark_color(m.kind);
|
||||||
let side = mark_side_color(m.side);
|
let side = mark_side_color(m.side);
|
||||||
draw_command_flag(m.x, m.y, Color::new(base.r, base.g, base.b, a), Color::new(side.r, side.g, side.b, a), t);
|
draw_command_flag(m.x, m.y, Color::new(base.r, base.g, base.b, a), Color::new(side.r, side.g, side.b, a), t);
|
||||||
|
|||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
// First Rhai mission-script runtime. This step supports log/objective/spawn hooks.
|
||||||
|
// Zone/branch/gameplay hooks come in the next implementation steps.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use rhai::{Dynamic, Engine, Scope};
|
||||||
|
|
||||||
|
use crate::game::EK;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ScriptAction {
|
||||||
|
Spawn { side: i32, kind: String, x: f32, y: f32, count: i32, level: i32 },
|
||||||
|
SpawnInZone { zone: String, kind: String, side: i32, count: i32, level: i32 },
|
||||||
|
Wave { count: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct MissionScript {
|
||||||
|
messages: Vec<String>,
|
||||||
|
objective: Option<String>,
|
||||||
|
actions: Vec<ScriptAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MissionScript {
|
||||||
|
pub fn new() -> MissionScript {
|
||||||
|
MissionScript { messages: Vec::new(), objective: None, actions: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn messages(&self) -> Vec<String> {
|
||||||
|
self.messages.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn objective(&self) -> Option<String> {
|
||||||
|
self.objective.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actions(&self) -> Vec<ScriptAction> {
|
||||||
|
self.actions.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(&mut self, other: MissionScript) {
|
||||||
|
self.messages.extend(other.messages);
|
||||||
|
self.actions.extend(other.actions);
|
||||||
|
if other.objective.is_some() {
|
||||||
|
self.objective = other.objective;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MissionScript {
|
||||||
|
fn default() -> Self {
|
||||||
|
MissionScript::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_side_name(name: &str) -> Option<i32> {
|
||||||
|
match name.trim().to_lowercase().as_str() {
|
||||||
|
"player" => Some(0),
|
||||||
|
"heroes" | "hero" => Some(1),
|
||||||
|
"mossgrim" => Some(2),
|
||||||
|
"nightwell" => Some(3),
|
||||||
|
"rival3" | "rival" => Some(4),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_spawn_kind(name: &str) -> Option<EK> {
|
||||||
|
match name.trim().to_lowercase().as_str() {
|
||||||
|
"worker" => Some(EK::Worker),
|
||||||
|
"kobold" => Some(EK::Kobold),
|
||||||
|
"spider" => Some(EK::Spider),
|
||||||
|
"mage" => Some(EK::Mage),
|
||||||
|
"ogre" => Some(EK::Ogre),
|
||||||
|
"knight" => Some(EK::Knight),
|
||||||
|
"archer" => Some(EK::Archer),
|
||||||
|
"priest" => Some(EK::Priest),
|
||||||
|
"dwarf" => Some(EK::Dwarf),
|
||||||
|
"paladin" => Some(EK::Paladin),
|
||||||
|
"jailer" => Some(EK::Jailer),
|
||||||
|
"priestess" => Some(EK::Priestess),
|
||||||
|
"chainbreaker" => Some(EK::Chainbreaker),
|
||||||
|
"bat" => Some(EK::Bat),
|
||||||
|
"gargoyle" => Some(EK::Gargoyle),
|
||||||
|
"owl" => Some(EK::Owl),
|
||||||
|
"wyrm" => Some(EK::Wyrm),
|
||||||
|
"cogling" => Some(EK::Cogling),
|
||||||
|
"revenant" => Some(EK::Revenant),
|
||||||
|
"giant" => Some(EK::Giant),
|
||||||
|
"fairy" => Some(EK::Fairy),
|
||||||
|
"battlemage" => Some(EK::BattleMage),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_start(source: &str) -> Result<MissionScript, String> {
|
||||||
|
run_hook(source, "on_start", MissionScript::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_hook(source: &str, hook: &str, initial: MissionScript) -> Result<MissionScript, String> {
|
||||||
|
let needle = format!("fn {hook}(");
|
||||||
|
if !source.contains(&needle) {
|
||||||
|
return Ok(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Arc::new(Mutex::new(initial));
|
||||||
|
let engine = script_engine(output.clone());
|
||||||
|
let code = format!("{}\n{hook}(m)", source);
|
||||||
|
let mut scope = Scope::new();
|
||||||
|
scope.push("m", MissionScript::new());
|
||||||
|
|
||||||
|
let returned = engine.eval_with_scope::<Dynamic>(&mut scope, &code).map_err(|e| e.to_string())?;
|
||||||
|
if let Some(returned) = returned.try_cast::<MissionScript>() {
|
||||||
|
if !returned.messages.is_empty() || !returned.actions.is_empty() || returned.objective.is_some() {
|
||||||
|
output.lock().map_err(|e| e.to_string())?.merge(returned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let done = output.lock().map_err(|e| e.to_string())?.clone();
|
||||||
|
Ok(done)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script_engine(output: Arc<Mutex<MissionScript>>) -> Engine {
|
||||||
|
let mut engine = Engine::new();
|
||||||
|
engine.set_max_operations(200_000);
|
||||||
|
engine.register_type_with_name::<MissionScript>("mission");
|
||||||
|
let log_output = output.clone();
|
||||||
|
engine.register_fn("log", move |_: &mut MissionScript, s: &str| {
|
||||||
|
if let Ok(mut m) = log_output.lock() {
|
||||||
|
m.messages.push(s.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let objective_output = output.clone();
|
||||||
|
engine.register_fn("objective", move |_: &mut MissionScript, s: &str| {
|
||||||
|
if let Ok(mut m) = objective_output.lock() {
|
||||||
|
m.objective = Some(s.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let spawn_output = output.clone();
|
||||||
|
engine.register_fn("spawn_at", move |_: &mut MissionScript, side: i64, kind: &str, x: f64, y: f64, count: i64, level: i64| {
|
||||||
|
if let Ok(mut m) = spawn_output.lock() {
|
||||||
|
m.actions.push(ScriptAction::Spawn { side: side as i32, kind: kind.to_string(), x: x as f32, y: y as f32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let spawn_side_output = output.clone();
|
||||||
|
engine.register_fn("spawn_at", move |_: &mut MissionScript, side: &str, kind: &str, x: f64, y: f64, count: i64, level: i64| {
|
||||||
|
if let Some(side) = parse_side_name(side) {
|
||||||
|
if let Ok(mut m) = spawn_side_output.lock() {
|
||||||
|
m.actions.push(ScriptAction::Spawn { side, kind: kind.to_string(), x: x as f32, y: y as f32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let zone_output = output.clone();
|
||||||
|
engine.register_fn("spawn_count_in_zone", move |_: &mut MissionScript, zone: &str, kind: &str, count: i64, side: i64, level: i64| {
|
||||||
|
if let Ok(mut m) = zone_output.lock() {
|
||||||
|
m.actions.push(ScriptAction::SpawnInZone { zone: zone.to_string(), kind: kind.to_string(), side: side as i32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let zone_side_output = output.clone();
|
||||||
|
engine.register_fn("spawn_count_in_zone", move |_: &mut MissionScript, zone: &str, kind: &str, count: i64, side: &str, level: i64| {
|
||||||
|
if let Some(side) = parse_side_name(side) {
|
||||||
|
if let Ok(mut m) = zone_side_output.lock() {
|
||||||
|
m.actions.push(ScriptAction::SpawnInZone { zone: zone.to_string(), kind: kind.to_string(), side, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let wave_output = output.clone();
|
||||||
|
engine.register_fn("spawn_wave", move |_: &mut MissionScript, count: i64| {
|
||||||
|
if let Ok(mut m) = wave_output.lock() {
|
||||||
|
m.actions.push(ScriptAction::Wave { count: count.clamp(1, 20) as i32 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
engine
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mission_script_can_log_and_change_the_objective() {
|
||||||
|
let source = r#"
|
||||||
|
fn on_start(m) {
|
||||||
|
m.log("Die Wachstube ist offen.");
|
||||||
|
m.objective("Finde drei Scherben.");
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let m = run_start(source).expect("the script runs");
|
||||||
|
assert_eq!(m.messages(), vec!["Die Wachstube ist offen.".to_string()]);
|
||||||
|
assert_eq!(m.objective().as_deref(), Some("Finde drei Scherben."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mission_script_can_record_spawn_actions_in_zones() {
|
||||||
|
let source = r#"
|
||||||
|
fn on_start(m) {
|
||||||
|
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let m = run_start(source).expect("the script runs");
|
||||||
|
assert_eq!(m.actions(), vec![ScriptAction::SpawnInZone { zone: "north_gate".into(), kind: "Knight".into(), side: 1, count: 3, level: 2 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mission_script_can_record_spawn_actions() {
|
||||||
|
let source = r#"
|
||||||
|
fn on_start(m) {
|
||||||
|
m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let m = run_start(source).expect("the script runs");
|
||||||
|
assert_eq!(m.actions(), vec![ScriptAction::Spawn { side: 1, kind: "Knight".into(), x: 21.5, y: 21.5, count: 3, level: 2 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mission_script_can_request_a_wave() {
|
||||||
|
let source = r#"
|
||||||
|
fn on_start(m) {
|
||||||
|
m.spawn_wave(3);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let m = run_start(source).expect("the script runs");
|
||||||
|
assert_eq!(m.actions(), vec![ScriptAction::Wave { count: 3 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_script_without_the_hook_is_allowed() {
|
||||||
|
let m = run_hook("fn unrelated(m) { m.log('x'); }", "on_start", MissionScript::new()).expect("no hook call");
|
||||||
|
assert!(m.messages().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broken_script_returns_an_error() {
|
||||||
|
let source = r#"
|
||||||
|
fn on_start(m) {
|
||||||
|
m.log("broken
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let error = run_start(source).expect_err("the parser rejects this script");
|
||||||
|
assert!(!error.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-1
@@ -150,7 +150,12 @@ pub struct Settings {
|
|||||||
pub ui_factor: u8, // interface size: 0 = automatic, 1..4 = fixed integer factor
|
pub ui_factor: u8, // interface size: 0 = automatic, 1..4 = fixed integer factor
|
||||||
pub render_scale: u8, // 3D world resolution divisor: 1 = full, 2..16 = pixel look
|
pub render_scale: u8, // 3D world resolution divisor: 1 = full, 2..16 = pixel look
|
||||||
pub touch_ui: u8, // touch interface: 0 = automatic (after the first touch), 1 = on, 2 = off
|
pub touch_ui: u8, // touch interface: 0 = automatic (after the first touch), 1 = on, 2 = off
|
||||||
pub debug_fps: bool, // show FPS and rendering backend
|
pub debug_fps: bool, // show FPS and rendering backend
|
||||||
|
pub debug_frame_time: bool, // show the frame-time line and graph
|
||||||
|
pub debug_ping: bool, // show network ping
|
||||||
|
pub debug_trigger_zones: bool, // draw mission trigger zones
|
||||||
|
pub debug_command_markers: bool, // draw extra command markers (guard/attack/gather stay fixed)
|
||||||
|
pub debug_path_lines: bool, // draw creature path lines
|
||||||
// game
|
// game
|
||||||
pub game_speed: f32, // 1 or 2
|
pub game_speed: f32, // 1 or 2
|
||||||
pub hints: bool,
|
pub hints: bool,
|
||||||
@@ -181,6 +186,11 @@ impl Default for Settings {
|
|||||||
render_scale: 1,
|
render_scale: 1,
|
||||||
touch_ui: 0,
|
touch_ui: 0,
|
||||||
debug_fps: false,
|
debug_fps: false,
|
||||||
|
debug_frame_time: false,
|
||||||
|
debug_ping: false,
|
||||||
|
debug_trigger_zones: false,
|
||||||
|
debug_command_markers: false,
|
||||||
|
debug_path_lines: false,
|
||||||
game_speed: 1.0,
|
game_speed: 1.0,
|
||||||
hints: true,
|
hints: true,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
@@ -236,10 +246,21 @@ mod tests {
|
|||||||
fn debug_fps_round_trips() {
|
fn debug_fps_round_trips() {
|
||||||
let mut s = Settings::default();
|
let mut s = Settings::default();
|
||||||
assert!(!s.debug_fps);
|
assert!(!s.debug_fps);
|
||||||
|
assert!(!s.debug_frame_time);
|
||||||
|
assert!(!s.debug_ping);
|
||||||
|
assert!(!s.debug_trigger_zones);
|
||||||
|
assert!(!s.debug_command_markers);
|
||||||
|
assert!(!s.debug_path_lines);
|
||||||
s.debug_fps = true;
|
s.debug_fps = true;
|
||||||
|
s.debug_trigger_zones = true;
|
||||||
|
s.debug_command_markers = true;
|
||||||
let l = Settings::from_json(&s.to_json()).expect("settings serialize back");
|
let l = Settings::from_json(&s.to_json()).expect("settings serialize back");
|
||||||
assert!(l.debug_fps);
|
assert!(l.debug_fps);
|
||||||
|
assert!(l.debug_trigger_zones);
|
||||||
|
assert!(l.debug_command_markers);
|
||||||
let old = Settings::from_json(r#"{"language":"en","map_w":64,"map_h":64}"#).expect("old settings remain readable");
|
let old = Settings::from_json(r#"{"language":"en","map_w":64,"map_h":64}"#).expect("old settings remain readable");
|
||||||
assert!(!old.debug_fps);
|
assert!(!old.debug_fps);
|
||||||
|
assert!(!old.debug_trigger_zones);
|
||||||
|
assert!(!old.debug_command_markers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user